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
5 changes: 4 additions & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ graph TB
PAY[Pay]
BUY[Buy]
COINS[Coins / UTXOs]
ANNOUNCEMENTS[Announcements]
CONSOLIDATION[Consolidation]

%% Dependencies to Core (all features depend on Core, but showing it explicitly would clutter the diagram)
%% Instead, we note this in the documentation below

%% Feature-to-feature dependencies (extracted from draw.io diagram)
ADDRESS_MGMT --> LABELS
ANNOUNCEMENTS --> SETTINGS
ANNOUNCEMENTS --> TX_HISTORY
APP_STARTUP --> WALLETS
AUTOSWAPS --> TRANSFER
BIP85 --> SECRETS
Expand Down Expand Up @@ -113,7 +116,7 @@ graph TB
classDef featureStyle fill:#1a202c,stroke:#2d3748,stroke-width:2px,color:#e2e8f0

class CORE coreStyle
class SETTINGS,TOR,PIN_CODE,LABELS,SECRETS,HW_WALLETS,BTC_PRICE,NETWORK,BIP85,FEES,WALLETS,EXCHANGE,APP_STARTUP,UTXO_MGMT,ADDRESS_MGMT,RECIPIENTS,FUNDING,BACKUPS,SWAPS,PAYJOIN,WITHDRAWAL,STATUS,SEND,RECEIVE,TRANSFER,TX_HISTORY,BG_TASKS,AUTOSWAPS,DCA,SELL,PAY,BUY,COINS,CONSOLIDATION featureStyle
class SETTINGS,TOR,PIN_CODE,LABELS,SECRETS,HW_WALLETS,BTC_PRICE,NETWORK,BIP85,FEES,WALLETS,EXCHANGE,APP_STARTUP,UTXO_MGMT,ADDRESS_MGMT,RECIPIENTS,FUNDING,BACKUPS,SWAPS,PAYJOIN,WITHDRAWAL,STATUS,SEND,RECEIVE,TRANSFER,TX_HISTORY,BG_TASKS,AUTOSWAPS,DCA,SELL,PAY,BUY,COINS,ANNOUNCEMENTS,CONSOLIDATION featureStyle
```

## About Package Dependency Diagrams
Expand Down
55 changes: 55 additions & 0 deletions lib/features/announcements/announcements_locator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart';
import 'package:bb_mobile/core/settings/domain/watch_payjoin_enabled_changes_usecase.dart';
import 'package:bb_mobile/core/storage/sqlite_database.dart';
import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transactions_usecase.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/watch_finished_wallet_syncs_usecase.dart';
import 'package:bb_mobile/features/announcements/data/announcement_dismissal_repository_impl.dart';
import 'package:bb_mobile/features/announcements/data/datasources/announcement_dismissal_datasource.dart';
import 'package:bb_mobile/features/announcements/domain/usecases/dismiss_announcement_usecase.dart';
import 'package:bb_mobile/features/announcements/domain/usecases/get_visible_announcements_usecase.dart';
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';
import 'package:bb_mobile/features/announcements/presentation/announcements_cubit.dart';
import 'package:get_it/get_it.dart';

class AnnouncementsLocator {
static void setup(GetIt locator) {
// Data
locator.registerLazySingleton<AnnouncementDismissalDatasource>(
() => AnnouncementDismissalDatasource(sqlite: locator<SqliteDatabase>()),
);
locator.registerLazySingleton<AnnouncementDismissalRepository>(
() => AnnouncementDismissalRepositoryImpl(
datasource: locator<AnnouncementDismissalDatasource>(),
),
);

// Use-cases
locator.registerFactory<GetVisibleAnnouncementsUsecase>(
() => GetVisibleAnnouncementsUsecase(
settingsRepository: locator<SettingsRepository>(),
getWalletTransactionsUsecase: locator<GetWalletTransactionsUsecase>(),
getAutoSwapSettingsUsecase: locator<GetAutoSwapSettingsUsecase>(),
dismissalRepository: locator<AnnouncementDismissalRepository>(),
),
);
locator.registerFactory<DismissAnnouncementUsecase>(
() => DismissAnnouncementUsecase(
dismissalRepository: locator<AnnouncementDismissalRepository>(),
),
);

// Presentation
locator.registerFactory<AnnouncementsCubit>(
() => AnnouncementsCubit(
getVisibleAnnouncementsUsecase:
locator<GetVisibleAnnouncementsUsecase>(),
dismissAnnouncementUsecase: locator<DismissAnnouncementUsecase>(),
watchPayjoinEnabledChangesUsecase:
locator<WatchPayjoinEnabledChangesUsecase>(),
watchFinishedWalletSyncsUsecase:
locator<WatchFinishedWalletSyncsUsecase>(),
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'package:bb_mobile/features/announcements/data/datasources/announcement_dismissal_datasource.dart';
import 'package:bb_mobile/features/announcements/data/mappers/announcement_dismissal_mapper.dart';
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart';
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';

class AnnouncementDismissalRepositoryImpl
implements AnnouncementDismissalRepository {
final AnnouncementDismissalDatasource _datasource;

AnnouncementDismissalRepositoryImpl({required this._datasource});

@override
Future<List<AnnouncementDismissal>> getDismissals() async {
final models = await _datasource.fetchAll();
// Drop rows whose id is unknown to this build (forward-compat downgrade).
return models
.map((m) => m.toEntity())
.whereType<AnnouncementDismissal>()
.toList();
}

@override
Future<void> dismiss(AnnouncementId id) async {
// Persist in UTC, per the `dismissed_announcements.dismissedAt` contract.
await _datasource.upsert(id.name, DateTime.now().toUtc());
}
}
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/models/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,
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:bb_mobile/features/announcements/data/models/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);
}
}
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,
});
}
20 changes: 20 additions & 0 deletions lib/features/announcements/domain/announcements_failure.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:bb_mobile/core/failures/failure.dart';

/// The announcements feature's sealed failure family (Flutter-free).
///
/// Its `toTranslated(BuildContext)` lives in the presentation layer
/// (`presentation/announcements_failure_l10n.dart`), never here.
sealed class AnnouncementsFailure extends Failure {
const AnnouncementsFailure([super.logMessage]);
}

/// A dismissal could not be read from or written to persistent storage.
final class AnnouncementStorageFailure extends AnnouncementsFailure {
const AnnouncementStorageFailure([super.logMessage]);
}

/// Catch-all for anything not modeled above. The UI renders a generic
/// localized message for this — never the raw [logMessage].
final class AnnouncementUnexpectedFailure extends AnnouncementsFailure {
const AnnouncementUnexpectedFailure([super.logMessage]);
}
101 changes: 101 additions & 0 deletions lib/features/announcements/domain/entities/announcement.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/// A home-screen announcement: a dismissible, tappable nudge shown in the
/// wallet-home carousel (e.g. "Increase privacy with Payjoin").
///
/// Announcements are defined in code (compile-time), not persisted — only the
/// per-user *dismissal* fact is stored (see `DismissedAnnouncements` table).
/// The user-facing title/description are NOT held here: they map to
/// localization keys in the presentation layer so `domain/` stays Flutter-free.
library;

/// Stable identifier for each announcement. The enum *name* is the persistence
/// key (stored in `dismissed_announcements.announcement_id`) and the l10n key
/// prefix, so **never rename or reorder existing values** — only append.
enum AnnouncementId {
/// Shown once the wallet has transaction history and payjoin is disabled,
/// inviting the user to enable payjoin for better on-chain privacy.
payjoinPrivacy,

/// Shown while autoswap is enabled, so the user is aware it's active and can
/// learn what it does.
autoswapActive,
}

/// Visual/semantic tone of an announcement, mapped to theme colors in the UI.
enum AnnouncementTone { info, warning, success }

/// What tapping the announcement's body does.
sealed class AnnouncementAction {
const AnnouncementAction();
}

/// 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 {
const NavigateAction();
}

/// How re-display works after the user dismisses an announcement.
sealed class DismissPolicy {
const DismissPolicy();
}

/// Once dismissed, never shown again (until its trigger condition itself
/// changes — which is decided by the trigger, not this policy).
final class PermanentDismiss extends DismissPolicy {
const PermanentDismiss();
}

/// Dismissed only temporarily: re-arms (becomes eligible again) once [interval]
/// has elapsed since the dismissal timestamp.
final class SnoozeDismiss extends DismissPolicy {
final Duration interval;

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

/// A rich, self-validating announcement definition.
///
/// Invalid instances are impossible to construct: the id is a closed enum, the
/// action and policy are sealed, and [priority] is validated in the
/// constructor. Ordering in the carousel is by ascending [priority].
class Announcement {
final AnnouncementId id;

/// Lower shows first in the carousel. Must be non-negative.
final int priority;

final AnnouncementTone tone;
final AnnouncementAction action;
final DismissPolicy dismissPolicy;

Announcement({
required this.id,
required this.priority,
required this.tone,
required this.action,
required this.dismissPolicy,
}) {
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
/// dismissals stop suppressing once the interval has elapsed.
bool isSuppressedBy(DateTime dismissedAt, {required DateTime now}) {
return switch (dismissPolicy) {
PermanentDismiss() => true,
SnoozeDismiss(:final interval) => now.isBefore(dismissedAt.add(interval)),
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';

/// The runtime signals a trigger can read to decide whether it fires.
///
/// Extend this (and the gathering in `GetVisibleAnnouncementsUsecase`) as new
/// announcements need new signals.
class AnnouncementSignals {
final bool isPayjoinEnabled;
final bool hasTransactionHistory;
final bool isAutoswapEnabled;

const AnnouncementSignals({
required this.isPayjoinEnabled,
required this.hasTransactionHistory,
required this.isAutoswapEnabled,
});
}

/// A catalog entry: an [Announcement] definition paired with the predicate that
/// decides whether it should appear given the current [AnnouncementSignals].
class AnnouncementCatalogEntry {
final Announcement announcement;

/// Predicate deciding whether this announcement should appear given the
/// current signals. Read through [triggersFor].
final bool Function(AnnouncementSignals signals) trigger;

const AnnouncementCatalogEntry({
required this.announcement,
required this.trigger,
});

bool triggersFor(AnnouncementSignals signals) => trigger(signals);
}

/// The single compile-time registry of every home announcement.
///
/// To add an announcement: append an [AnnouncementId] value, add a catalog
/// entry here with its trigger, and add the title/description l10n mapping in
/// `presentation/announcement_l10n.dart`.
final List<AnnouncementCatalogEntry> announcementCatalog = [
AnnouncementCatalogEntry(
announcement: Announcement(
id: AnnouncementId.payjoinPrivacy,
priority: 0,
tone: AnnouncementTone.info,
action: const NavigateAction(),
dismissPolicy: const PermanentDismiss(),
),
// Show once the wallet has received/transacted (first UTXO or history after
// create/recover) AND payjoin is still off — nudging the privacy upgrade.
trigger: (s) => s.hasTransactionHistory && !s.isPayjoinEnabled,
),
AnnouncementCatalogEntry(
announcement: Announcement(
id: AnnouncementId.autoswapActive,
priority: 1,
tone: AnnouncementTone.success,
action: const NavigateAction(),
dismissPolicy: const PermanentDismiss(),
),
// Show while autoswap is enabled, letting the user learn what it does.
trigger: (s) => s.isAutoswapEnabled,
),
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';

/// A record that the user dismissed a given announcement at a given time.
///
/// Domain entity handed out by `AnnouncementDismissalRepository`; the wire/DB
/// shape lives in `data/models/` and never crosses the repository boundary.
class AnnouncementDismissal {
final AnnouncementId id;
final DateTime dismissedAt;

AnnouncementDismissal({required this.id, required this.dismissedAt});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart';

/// The single source of truth for which announcements the user has dismissed.
///
/// Returns and accepts only domain types (never a Drift row / model). The
/// implementation lives in `data/` and wraps the `dismissed_announcements`
/// table.
abstract interface class AnnouncementDismissalRepository {
/// All recorded dismissals, keyed by announcement id.
Future<List<AnnouncementDismissal>> getDismissals();

/// Records (or refreshes) a dismissal for [id] at the current time.
Future<void> dismiss(AnnouncementId id);
}
Loading