Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'package:bb_mobile/core/storage/sqlite_database.dart';
import 'package:bb_mobile/features/announcements/data/announcement_dismissal_model.dart';

/// Wraps the `dismissed_announcements` Drift table. Private to its repository;
/// speaks the wire/persistence shape (`AnnouncementDismissalModel`), never a
/// domain entity.
class AnnouncementDismissalDatasource {
final SqliteDatabase _sqlite;

AnnouncementDismissalDatasource({required this._sqlite});

Future<List<AnnouncementDismissalModel>> fetchAll() async {
final rows = await _sqlite.managers.dismissedAnnouncements.get();
return rows
.map(
(r) => AnnouncementDismissalModel(
announcementId: r.announcementId,
dismissedAt: r.dismissedAt,
),
)
.toList();
}

/// Upserts the dismissal: inserts a new row or refreshes the timestamp of an
/// existing one (keyed by [announcementId]).
Future<void> upsert(String announcementId, DateTime dismissedAt) async {
await _sqlite
.into(_sqlite.dismissedAnnouncements)
.insertOnConflictUpdate(
DismissedAnnouncementsCompanion.insert(
announcementId: announcementId,
dismissedAt: dismissedAt,
),
);
}
}
17 changes: 17 additions & 0 deletions lib/features/announcements/data/announcement_dismissal_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:bb_mobile/features/announcements/data/announcement_dismissal_model.dart';
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart';

/// Translates the persisted dismissal model to the domain entity.
extension AnnouncementDismissalMapper on AnnouncementDismissalModel {
/// Returns the domain entity, or `null` when the stored id is not a known
/// [AnnouncementId] (e.g. a row written by a newer build, then downgraded) —
/// callers skip unknown ids rather than crash.
AnnouncementDismissal? toEntity() {
final id = AnnouncementId.values
.where((v) => v.name == announcementId)
.firstOrNull;
if (id == null) return null;
return AnnouncementDismissal(id: id, dismissedAt: dismissedAt);
}
}
13 changes: 13 additions & 0 deletions lib/features/announcements/data/announcement_dismissal_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/// Wire/persistence shape of a dismissal record. Pure data — mirrors the
/// `dismissed_announcements` Drift row. Never crosses the repository boundary
/// (the repo maps it to `AnnouncementDismissal`).
class AnnouncementDismissalModel {
/// The `AnnouncementId` enum name as stored.
final String announcementId;
final DateTime dismissedAt;

const AnnouncementDismissalModel({
required this.announcementId,
required this.dismissedAt,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class AnnouncementDismissalRepositoryImpl

@override
Future<void> dismiss(AnnouncementId id) async {
await _datasource.upsert(id.name, DateTime.now());
// Persist in UTC, per the `dismissed_announcements.dismissedAt` contract.
await _datasource.upsert(id.name, DateTime.now().toUtc());
}
}
27 changes: 18 additions & 9 deletions lib/features/announcements/domain/entities/announcement.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,11 @@ sealed class AnnouncementAction {
const AnnouncementAction();
}

/// Navigate to a named GoRouter route (by route *name*, not path, to avoid
/// coupling to another feature's path layout).
/// Tapping the announcement navigates somewhere. The concrete destination is
/// resolved in the ui layer (`ui/announcement_navigation.dart`) from the
/// [Announcement]'s id, so `domain/` never imports another feature's router.
final class NavigateAction extends AnnouncementAction {
final String routeName;

const NavigateAction(this.routeName)
: assert(routeName != '', 'routeName must not be empty');
const NavigateAction();
}

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

SnoozeDismiss(this.interval)
: assert(interval.inMicroseconds > 0, 'snooze interval must be positive');
SnoozeDismiss(this.interval) {
if (interval.inMicroseconds <= 0) {
throw ArgumentError.value(
interval,
'interval',
'snooze interval must be positive',
);
}
}
}

/// A rich, self-validating announcement definition.
Expand All @@ -78,7 +83,11 @@ class Announcement {
required this.tone,
required this.action,
required this.dismissPolicy,
}) : assert(priority >= 0, 'priority must be non-negative');
}) {
if (priority < 0) {
throw ArgumentError.value(priority, 'priority', 'must be non-negative');
}
}

/// Whether a dismissal recorded at [dismissedAt] still suppresses this
/// announcement as of [now]. Permanent dismissals always suppress; snooze
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
import 'package:bb_mobile/features/settings/ui/settings_router.dart';

/// The runtime signals a trigger can read to decide whether it fires.
///
Expand Down Expand Up @@ -45,7 +44,7 @@ final List<AnnouncementCatalogEntry> announcementCatalog = [
id: AnnouncementId.payjoinPrivacy,
priority: 0,
tone: AnnouncementTone.info,
action: NavigateAction(SettingsRoute.payjoinSettings.name),
action: const NavigateAction(),
dismissPolicy: const PermanentDismiss(),
),
// Show once the wallet has received/transacted (first UTXO or history after
Expand All @@ -57,7 +56,7 @@ final List<AnnouncementCatalogEntry> announcementCatalog = [
id: AnnouncementId.autoswapActive,
priority: 1,
tone: AnnouncementTone.success,
action: NavigateAction(SettingsRoute.autoswapSettings.name),
action: const NavigateAction(),
dismissPolicy: const PermanentDismiss(),
),
// Show while autoswap is enabled, letting the user learn what it does.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ class GetVisibleAnnouncementsUsecase {

Future<Result<List<Announcement>, AnnouncementsFailure>> execute() async {
try {
final settings = await _settingsRepository.fetch();
final transactions = await _getWalletTransactionsUsecase.execute();
final autoSwap = await _getAutoSwapSettingsUsecase.execute();
final dismissals = await _dismissalRepository.getDismissals();
// The four sources are independent, so gather them concurrently.
final (settings, transactions, autoSwap, dismissals) = await (
_settingsRepository.fetch(),
_getWalletTransactionsUsecase.execute(),
_getAutoSwapSettingsUsecase.execute(),
_dismissalRepository.getDismissals(),
).wait;

final signals = AnnouncementSignals(
isPayjoinEnabled: settings.isPayjoinEnabled,
Expand All @@ -42,7 +45,7 @@ class GetVisibleAnnouncementsUsecase {
);

final dismissedAtById = {for (final d in dismissals) d.id: d.dismissedAt};
final now = DateTime.now();
final now = DateTime.now().toUtc();

final visible = <Announcement>[];
for (final entry in announcementCatalog) {
Expand All @@ -60,7 +63,9 @@ class GetVisibleAnnouncementsUsecase {
visible.sort((a, b) => a.priority.compareTo(b.priority));
return Ok(visible);
} catch (e) {
return Err(AnnouncementStorageFailure(e.toString()));
// Sources span settings/tx/autoswap/storage, so this is a genuine
// catch-all rather than a storage-only failure.
return Err(AnnouncementUnexpectedFailure(e.toString()));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart';
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';

/// Records that the user dismissed an announcement, so it stops showing
/// (permanently or until its snooze interval elapses, per its dismiss policy).
class DismissAnnouncementUsecase {
final AnnouncementDismissalRepository _dismissalRepository;

DismissAnnouncementUsecase({required this._dismissalRepository});

Future<Result<void, AnnouncementsFailure>> execute(AnnouncementId id) async {
try {
await _dismissalRepository.dismiss(id);
return const Ok(null);
} catch (e) {
return Err(AnnouncementStorageFailure(e.toString()));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart';
import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transactions_usecase.dart';
import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart';
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
import 'package:bb_mobile/features/announcements/domain/entities/announcement_catalog.dart';
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';

/// Orchestrates which announcements are currently visible on the home carousel.
///
/// Thin orchestration only: it gathers the trigger signals (payjoin setting,
/// whether the wallet has transaction history), asks each catalog entry whether
/// its trigger fires, drops anything the user has dismissed (respecting the
/// per-announcement dismiss policy), and returns the survivors ordered by
/// ascending priority. All decision *rules* live on the entities / catalog;
/// this use-case only wires signals to them.
class GetVisibleAnnouncementsUsecase {
final SettingsRepository _settingsRepository;
final GetWalletTransactionsUsecase _getWalletTransactionsUsecase;
final GetAutoSwapSettingsUsecase _getAutoSwapSettingsUsecase;
final AnnouncementDismissalRepository _dismissalRepository;

GetVisibleAnnouncementsUsecase({
required this._settingsRepository,
required this._getWalletTransactionsUsecase,
required this._getAutoSwapSettingsUsecase,
required this._dismissalRepository,
});

Future<Result<List<Announcement>, AnnouncementsFailure>> execute() async {
try {
// The four sources are independent, so gather them concurrently.
final (settings, transactions, autoSwap, dismissals) = await (
_settingsRepository.fetch(),
_getWalletTransactionsUsecase.execute(),
_getAutoSwapSettingsUsecase.execute(),
_dismissalRepository.getDismissals(),
).wait;

final signals = AnnouncementSignals(
isPayjoinEnabled: settings.isPayjoinEnabled,
hasTransactionHistory: transactions.isNotEmpty,
isAutoswapEnabled: autoSwap.enabled,
);

final dismissedAtById = {for (final d in dismissals) d.id: d.dismissedAt};
final now = DateTime.now().toUtc();

final visible = <Announcement>[];
for (final entry in announcementCatalog) {
if (!entry.triggersFor(signals)) continue;

final dismissedAt = dismissedAtById[entry.announcement.id];
final suppressed =
dismissedAt != null &&
entry.announcement.isSuppressedBy(dismissedAt, now: now);
if (suppressed) continue;

visible.add(entry.announcement);
}

visible.sort((a, b) => a.priority.compareTo(b.priority));
return Ok(visible);
} catch (e) {
// Sources span settings/tx/autoswap/storage, so this is a genuine
// catch-all rather than a storage-only failure.
return Err(AnnouncementUnexpectedFailure(e.toString()));
}
}
}
32 changes: 27 additions & 5 deletions lib/features/announcements/presentation/announcements_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class AnnouncementsCubit extends Cubit<AnnouncementsState> {
StreamSubscription<bool>? _payjoinEnabledSub;
StreamSubscription<Wallet>? _walletSyncSub;

bool _refreshing = false;
bool _refreshQueued = false;

AnnouncementsCubit({
required this._getVisibleAnnouncementsUsecase,
required this._dismissAnnouncementUsecase,
Expand All @@ -47,18 +50,37 @@ class AnnouncementsCubit extends Cubit<AnnouncementsState> {

/// (Re)loads the visible announcements. Called on mount and whenever a
/// trigger signal changes.
///
/// Overlapping calls are coalesced: a request arriving while a load is in
/// flight re-runs once after it completes, so several wallets syncing
/// back-to-back can't spawn redundant, out-of-order loads.
Future<void> refresh() async {
final result = await _getVisibleAnnouncementsUsecase.execute();
result.fold(
(announcements) => emit(AnnouncementsState(announcements: announcements)),
(failure) => emit(state.copyWith(failure: failure)),
);
if (_refreshing) {
_refreshQueued = true;
return;
}
_refreshing = true;
try {
do {
_refreshQueued = false;
final result = await _getVisibleAnnouncementsUsecase.execute();
if (isClosed) return;
result.fold(
(announcements) =>
emit(AnnouncementsState(announcements: announcements)),
(failure) => emit(state.copyWith(failure: failure)),
);
} while (_refreshQueued);
} finally {
_refreshing = false;
}
}

/// Records a dismissal and refreshes the list (which collapses the section
/// when the last card is dismissed).
Future<void> dismiss(AnnouncementId id) async {
final result = await _dismissAnnouncementUsecase.execute(id);
if (isClosed) return;
await result.fold(
(_) => refresh(),
(failure) async => emit(state.copyWith(failure: failure)),
Expand Down
14 changes: 14 additions & 0 deletions lib/features/announcements/ui/announcement_navigation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
import 'package:bb_mobile/features/settings/ui/settings_router.dart';

/// Resolves each announcement to the route its [NavigateAction] opens.
///
/// Lives in the ui layer so `domain/` never imports another feature's router
/// (AGENTS.md rule #1 + Flutter-free domain). The exhaustive `switch` over the
/// closed [AnnouncementId] enum makes a missing mapping a compile-time warning.
extension AnnouncementNavigation on Announcement {
SettingsRoute get route => switch (id) {
AnnouncementId.payjoinPrivacy => SettingsRoute.payjoinSettings,
AnnouncementId.autoswapActive => SettingsRoute.autoswapSettings,
};
}
Loading