Skip to content

Commit abcc731

Browse files
committed
refactor(announcements): resolve routes in ui and harden cubit
Requested by @ethicnology in #2484.
1 parent 110a6b2 commit abcc731

16 files changed

Lines changed: 404 additions & 57 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import 'package:bb_mobile/core/storage/sqlite_database.dart';
2+
import 'package:bb_mobile/features/announcements/data/announcement_dismissal_model.dart';
3+
4+
/// Wraps the `dismissed_announcements` Drift table. Private to its repository;
5+
/// speaks the wire/persistence shape (`AnnouncementDismissalModel`), never a
6+
/// domain entity.
7+
class AnnouncementDismissalDatasource {
8+
final SqliteDatabase _sqlite;
9+
10+
AnnouncementDismissalDatasource({required this._sqlite});
11+
12+
Future<List<AnnouncementDismissalModel>> fetchAll() async {
13+
final rows = await _sqlite.managers.dismissedAnnouncements.get();
14+
return rows
15+
.map(
16+
(r) => AnnouncementDismissalModel(
17+
announcementId: r.announcementId,
18+
dismissedAt: r.dismissedAt,
19+
),
20+
)
21+
.toList();
22+
}
23+
24+
/// Upserts the dismissal: inserts a new row or refreshes the timestamp of an
25+
/// existing one (keyed by [announcementId]).
26+
Future<void> upsert(String announcementId, DateTime dismissedAt) async {
27+
await _sqlite
28+
.into(_sqlite.dismissedAnnouncements)
29+
.insertOnConflictUpdate(
30+
DismissedAnnouncementsCompanion.insert(
31+
announcementId: announcementId,
32+
dismissedAt: dismissedAt,
33+
),
34+
);
35+
}
36+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import 'package:bb_mobile/features/announcements/data/announcement_dismissal_model.dart';
2+
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
3+
import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart';
4+
5+
/// Translates the persisted dismissal model to the domain entity.
6+
extension AnnouncementDismissalMapper on AnnouncementDismissalModel {
7+
/// Returns the domain entity, or `null` when the stored id is not a known
8+
/// [AnnouncementId] (e.g. a row written by a newer build, then downgraded) —
9+
/// callers skip unknown ids rather than crash.
10+
AnnouncementDismissal? toEntity() {
11+
final id = AnnouncementId.values
12+
.where((v) => v.name == announcementId)
13+
.firstOrNull;
14+
if (id == null) return null;
15+
return AnnouncementDismissal(id: id, dismissedAt: dismissedAt);
16+
}
17+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/// Wire/persistence shape of a dismissal record. Pure data — mirrors the
2+
/// `dismissed_announcements` Drift row. Never crosses the repository boundary
3+
/// (the repo maps it to `AnnouncementDismissal`).
4+
class AnnouncementDismissalModel {
5+
/// The `AnnouncementId` enum name as stored.
6+
final String announcementId;
7+
final DateTime dismissedAt;
8+
9+
const AnnouncementDismissalModel({
10+
required this.announcementId,
11+
required this.dismissedAt,
12+
});
13+
}

lib/features/announcements/data/announcement_dismissal_repository_impl.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class AnnouncementDismissalRepositoryImpl
2222

2323
@override
2424
Future<void> dismiss(AnnouncementId id) async {
25-
await _datasource.upsert(id.name, DateTime.now());
25+
// Persist in UTC, per the `dismissed_announcements.dismissedAt` contract.
26+
await _datasource.upsert(id.name, DateTime.now().toUtc());
2627
}
2728
}

lib/features/announcements/domain/entities/announcement.dart

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,11 @@ sealed class AnnouncementAction {
2828
const AnnouncementAction();
2929
}
3030

31-
/// Navigate to a named GoRouter route (by route *name*, not path, to avoid
32-
/// coupling to another feature's path layout).
31+
/// Tapping the announcement navigates somewhere. The concrete destination is
32+
/// resolved in the ui layer (`ui/announcement_navigation.dart`) from the
33+
/// [Announcement]'s id, so `domain/` never imports another feature's router.
3334
final class NavigateAction extends AnnouncementAction {
34-
final String routeName;
35-
36-
const NavigateAction(this.routeName)
37-
: assert(routeName != '', 'routeName must not be empty');
35+
const NavigateAction();
3836
}
3937

4038
/// How re-display works after the user dismisses an announcement.
@@ -53,8 +51,15 @@ final class PermanentDismiss extends DismissPolicy {
5351
final class SnoozeDismiss extends DismissPolicy {
5452
final Duration interval;
5553

56-
SnoozeDismiss(this.interval)
57-
: assert(interval.inMicroseconds > 0, 'snooze interval must be positive');
54+
SnoozeDismiss(this.interval) {
55+
if (interval.inMicroseconds <= 0) {
56+
throw ArgumentError.value(
57+
interval,
58+
'interval',
59+
'snooze interval must be positive',
60+
);
61+
}
62+
}
5863
}
5964

6065
/// A rich, self-validating announcement definition.
@@ -78,7 +83,11 @@ class Announcement {
7883
required this.tone,
7984
required this.action,
8085
required this.dismissPolicy,
81-
}) : assert(priority >= 0, 'priority must be non-negative');
86+
}) {
87+
if (priority < 0) {
88+
throw ArgumentError.value(priority, 'priority', 'must be non-negative');
89+
}
90+
}
8291

8392
/// Whether a dismissal recorded at [dismissedAt] still suppresses this
8493
/// announcement as of [now]. Permanent dismissals always suppress; snooze

lib/features/announcements/domain/entities/announcement_catalog.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
2-
import 'package:bb_mobile/features/settings/ui/settings_router.dart';
32

43
/// The runtime signals a trigger can read to decide whether it fires.
54
///
@@ -45,7 +44,7 @@ final List<AnnouncementCatalogEntry> announcementCatalog = [
4544
id: AnnouncementId.payjoinPrivacy,
4645
priority: 0,
4746
tone: AnnouncementTone.info,
48-
action: NavigateAction(SettingsRoute.payjoinSettings.name),
47+
action: const NavigateAction(),
4948
dismissPolicy: const PermanentDismiss(),
5049
),
5150
// Show once the wallet has received/transacted (first UTXO or history after
@@ -57,7 +56,7 @@ final List<AnnouncementCatalogEntry> announcementCatalog = [
5756
id: AnnouncementId.autoswapActive,
5857
priority: 1,
5958
tone: AnnouncementTone.success,
60-
action: NavigateAction(SettingsRoute.autoswapSettings.name),
59+
action: const NavigateAction(),
6160
dismissPolicy: const PermanentDismiss(),
6261
),
6362
// Show while autoswap is enabled, letting the user learn what it does.

lib/features/announcements/domain/get_visible_announcements_usecase.dart

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ class GetVisibleAnnouncementsUsecase {
3030

3131
Future<Result<List<Announcement>, AnnouncementsFailure>> execute() async {
3232
try {
33-
final settings = await _settingsRepository.fetch();
34-
final transactions = await _getWalletTransactionsUsecase.execute();
35-
final autoSwap = await _getAutoSwapSettingsUsecase.execute();
36-
final dismissals = await _dismissalRepository.getDismissals();
33+
// The four sources are independent, so gather them concurrently.
34+
final (settings, transactions, autoSwap, dismissals) = await (
35+
_settingsRepository.fetch(),
36+
_getWalletTransactionsUsecase.execute(),
37+
_getAutoSwapSettingsUsecase.execute(),
38+
_dismissalRepository.getDismissals(),
39+
).wait;
3740

3841
final signals = AnnouncementSignals(
3942
isPayjoinEnabled: settings.isPayjoinEnabled,
@@ -42,7 +45,7 @@ class GetVisibleAnnouncementsUsecase {
4245
);
4346

4447
final dismissedAtById = {for (final d in dismissals) d.id: d.dismissedAt};
45-
final now = DateTime.now();
48+
final now = DateTime.now().toUtc();
4649

4750
final visible = <Announcement>[];
4851
for (final entry in announcementCatalog) {
@@ -60,7 +63,9 @@ class GetVisibleAnnouncementsUsecase {
6063
visible.sort((a, b) => a.priority.compareTo(b.priority));
6164
return Ok(visible);
6265
} catch (e) {
63-
return Err(AnnouncementStorageFailure(e.toString()));
66+
// Sources span settings/tx/autoswap/storage, so this is a genuine
67+
// catch-all rather than a storage-only failure.
68+
return Err(AnnouncementUnexpectedFailure(e.toString()));
6469
}
6570
}
6671
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'package:bb_mobile/core/utils/result.dart';
2+
import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart';
3+
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
4+
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';
5+
6+
/// Records that the user dismissed an announcement, so it stops showing
7+
/// (permanently or until its snooze interval elapses, per its dismiss policy).
8+
class DismissAnnouncementUsecase {
9+
final AnnouncementDismissalRepository _dismissalRepository;
10+
11+
DismissAnnouncementUsecase({required this._dismissalRepository});
12+
13+
Future<Result<void, AnnouncementsFailure>> execute(AnnouncementId id) async {
14+
try {
15+
await _dismissalRepository.dismiss(id);
16+
return const Ok(null);
17+
} catch (e) {
18+
return Err(AnnouncementStorageFailure(e.toString()));
19+
}
20+
}
21+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart';
2+
import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.dart';
3+
import 'package:bb_mobile/core/utils/result.dart';
4+
import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transactions_usecase.dart';
5+
import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart';
6+
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
7+
import 'package:bb_mobile/features/announcements/domain/entities/announcement_catalog.dart';
8+
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';
9+
10+
/// Orchestrates which announcements are currently visible on the home carousel.
11+
///
12+
/// Thin orchestration only: it gathers the trigger signals (payjoin setting,
13+
/// whether the wallet has transaction history), asks each catalog entry whether
14+
/// its trigger fires, drops anything the user has dismissed (respecting the
15+
/// per-announcement dismiss policy), and returns the survivors ordered by
16+
/// ascending priority. All decision *rules* live on the entities / catalog;
17+
/// this use-case only wires signals to them.
18+
class GetVisibleAnnouncementsUsecase {
19+
final SettingsRepository _settingsRepository;
20+
final GetWalletTransactionsUsecase _getWalletTransactionsUsecase;
21+
final GetAutoSwapSettingsUsecase _getAutoSwapSettingsUsecase;
22+
final AnnouncementDismissalRepository _dismissalRepository;
23+
24+
GetVisibleAnnouncementsUsecase({
25+
required this._settingsRepository,
26+
required this._getWalletTransactionsUsecase,
27+
required this._getAutoSwapSettingsUsecase,
28+
required this._dismissalRepository,
29+
});
30+
31+
Future<Result<List<Announcement>, AnnouncementsFailure>> execute() async {
32+
try {
33+
// The four sources are independent, so gather them concurrently.
34+
final (settings, transactions, autoSwap, dismissals) = await (
35+
_settingsRepository.fetch(),
36+
_getWalletTransactionsUsecase.execute(),
37+
_getAutoSwapSettingsUsecase.execute(),
38+
_dismissalRepository.getDismissals(),
39+
).wait;
40+
41+
final signals = AnnouncementSignals(
42+
isPayjoinEnabled: settings.isPayjoinEnabled,
43+
hasTransactionHistory: transactions.isNotEmpty,
44+
isAutoswapEnabled: autoSwap.enabled,
45+
);
46+
47+
final dismissedAtById = {for (final d in dismissals) d.id: d.dismissedAt};
48+
final now = DateTime.now().toUtc();
49+
50+
final visible = <Announcement>[];
51+
for (final entry in announcementCatalog) {
52+
if (!entry.triggersFor(signals)) continue;
53+
54+
final dismissedAt = dismissedAtById[entry.announcement.id];
55+
final suppressed =
56+
dismissedAt != null &&
57+
entry.announcement.isSuppressedBy(dismissedAt, now: now);
58+
if (suppressed) continue;
59+
60+
visible.add(entry.announcement);
61+
}
62+
63+
visible.sort((a, b) => a.priority.compareTo(b.priority));
64+
return Ok(visible);
65+
} catch (e) {
66+
// Sources span settings/tx/autoswap/storage, so this is a genuine
67+
// catch-all rather than a storage-only failure.
68+
return Err(AnnouncementUnexpectedFailure(e.toString()));
69+
}
70+
}
71+
}

lib/features/announcements/presentation/announcements_cubit.dart

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ class AnnouncementsCubit extends Cubit<AnnouncementsState> {
2929
StreamSubscription<bool>? _payjoinEnabledSub;
3030
StreamSubscription<Wallet>? _walletSyncSub;
3131

32+
bool _refreshing = false;
33+
bool _refreshQueued = false;
34+
3235
AnnouncementsCubit({
3336
required this._getVisibleAnnouncementsUsecase,
3437
required this._dismissAnnouncementUsecase,
@@ -47,18 +50,37 @@ class AnnouncementsCubit extends Cubit<AnnouncementsState> {
4750

4851
/// (Re)loads the visible announcements. Called on mount and whenever a
4952
/// trigger signal changes.
53+
///
54+
/// Overlapping calls are coalesced: a request arriving while a load is in
55+
/// flight re-runs once after it completes, so several wallets syncing
56+
/// back-to-back can't spawn redundant, out-of-order loads.
5057
Future<void> refresh() async {
51-
final result = await _getVisibleAnnouncementsUsecase.execute();
52-
result.fold(
53-
(announcements) => emit(AnnouncementsState(announcements: announcements)),
54-
(failure) => emit(state.copyWith(failure: failure)),
55-
);
58+
if (_refreshing) {
59+
_refreshQueued = true;
60+
return;
61+
}
62+
_refreshing = true;
63+
try {
64+
do {
65+
_refreshQueued = false;
66+
final result = await _getVisibleAnnouncementsUsecase.execute();
67+
if (isClosed) return;
68+
result.fold(
69+
(announcements) =>
70+
emit(AnnouncementsState(announcements: announcements)),
71+
(failure) => emit(state.copyWith(failure: failure)),
72+
);
73+
} while (_refreshQueued);
74+
} finally {
75+
_refreshing = false;
76+
}
5677
}
5778

5879
/// Records a dismissal and refreshes the list (which collapses the section
5980
/// when the last card is dismissed).
6081
Future<void> dismiss(AnnouncementId id) async {
6182
final result = await _dismissAnnouncementUsecase.execute(id);
83+
if (isClosed) return;
6284
await result.fold(
6385
(_) => refresh(),
6486
(failure) async => emit(state.copyWith(failure: failure)),

0 commit comments

Comments
 (0)