Skip to content

Commit 68c037b

Browse files
authored
Merge pull request #2484 from SatoshiPortal/pj/07-announcements
feat(announcements): dismissible home announcement carousel
2 parents 8893f01 + 5ae462e commit 68c037b

34 files changed

Lines changed: 1411 additions & 22 deletions

FEATURES.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,16 @@ graph TB
4545
PAY[Pay]
4646
BUY[Buy]
4747
COINS[Coins / UTXOs]
48+
ANNOUNCEMENTS[Announcements]
4849
CONSOLIDATION[Consolidation]
4950
5051
%% Dependencies to Core (all features depend on Core, but showing it explicitly would clutter the diagram)
5152
%% Instead, we note this in the documentation below
5253
5354
%% Feature-to-feature dependencies (extracted from draw.io diagram)
5455
ADDRESS_MGMT --> LABELS
56+
ANNOUNCEMENTS --> SETTINGS
57+
ANNOUNCEMENTS --> TX_HISTORY
5558
APP_STARTUP --> WALLETS
5659
AUTOSWAPS --> TRANSFER
5760
BIP85 --> SECRETS
@@ -113,7 +116,7 @@ graph TB
113116
classDef featureStyle fill:#1a202c,stroke:#2d3748,stroke-width:2px,color:#e2e8f0
114117
115118
class CORE coreStyle
116-
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
119+
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
117120
```
118121

119122
## About Package Dependency Diagrams
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart';
2+
import 'package:bb_mobile/core/settings/domain/watch_payjoin_enabled_changes_usecase.dart';
3+
import 'package:bb_mobile/core/storage/sqlite_database.dart';
4+
import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.dart';
5+
import 'package:bb_mobile/core/wallet/domain/usecases/get_wallet_transactions_usecase.dart';
6+
import 'package:bb_mobile/core/wallet/domain/usecases/watch_finished_wallet_syncs_usecase.dart';
7+
import 'package:bb_mobile/features/announcements/data/announcement_dismissal_repository_impl.dart';
8+
import 'package:bb_mobile/features/announcements/data/datasources/announcement_dismissal_datasource.dart';
9+
import 'package:bb_mobile/features/announcements/domain/usecases/dismiss_announcement_usecase.dart';
10+
import 'package:bb_mobile/features/announcements/domain/usecases/get_visible_announcements_usecase.dart';
11+
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';
12+
import 'package:bb_mobile/features/announcements/presentation/announcements_cubit.dart';
13+
import 'package:get_it/get_it.dart';
14+
15+
class AnnouncementsLocator {
16+
static void setup(GetIt locator) {
17+
// Data
18+
locator.registerLazySingleton<AnnouncementDismissalDatasource>(
19+
() => AnnouncementDismissalDatasource(sqlite: locator<SqliteDatabase>()),
20+
);
21+
locator.registerLazySingleton<AnnouncementDismissalRepository>(
22+
() => AnnouncementDismissalRepositoryImpl(
23+
datasource: locator<AnnouncementDismissalDatasource>(),
24+
),
25+
);
26+
27+
// Use-cases
28+
locator.registerFactory<GetVisibleAnnouncementsUsecase>(
29+
() => GetVisibleAnnouncementsUsecase(
30+
settingsRepository: locator<SettingsRepository>(),
31+
getWalletTransactionsUsecase: locator<GetWalletTransactionsUsecase>(),
32+
getAutoSwapSettingsUsecase: locator<GetAutoSwapSettingsUsecase>(),
33+
dismissalRepository: locator<AnnouncementDismissalRepository>(),
34+
),
35+
);
36+
locator.registerFactory<DismissAnnouncementUsecase>(
37+
() => DismissAnnouncementUsecase(
38+
dismissalRepository: locator<AnnouncementDismissalRepository>(),
39+
),
40+
);
41+
42+
// Presentation
43+
locator.registerFactory<AnnouncementsCubit>(
44+
() => AnnouncementsCubit(
45+
getVisibleAnnouncementsUsecase:
46+
locator<GetVisibleAnnouncementsUsecase>(),
47+
dismissAnnouncementUsecase: locator<DismissAnnouncementUsecase>(),
48+
watchPayjoinEnabledChangesUsecase:
49+
locator<WatchPayjoinEnabledChangesUsecase>(),
50+
watchFinishedWalletSyncsUsecase:
51+
locator<WatchFinishedWalletSyncsUsecase>(),
52+
),
53+
);
54+
}
55+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import 'package:bb_mobile/features/announcements/data/datasources/announcement_dismissal_datasource.dart';
2+
import 'package:bb_mobile/features/announcements/data/mappers/announcement_dismissal_mapper.dart';
3+
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
4+
import 'package:bb_mobile/features/announcements/domain/entities/announcement_dismissal.dart';
5+
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';
6+
7+
class AnnouncementDismissalRepositoryImpl
8+
implements AnnouncementDismissalRepository {
9+
final AnnouncementDismissalDatasource _datasource;
10+
11+
AnnouncementDismissalRepositoryImpl({required this._datasource});
12+
13+
@override
14+
Future<List<AnnouncementDismissal>> getDismissals() async {
15+
final models = await _datasource.fetchAll();
16+
// Drop rows whose id is unknown to this build (forward-compat downgrade).
17+
return models
18+
.map((m) => m.toEntity())
19+
.whereType<AnnouncementDismissal>()
20+
.toList();
21+
}
22+
23+
@override
24+
Future<void> dismiss(AnnouncementId id) async {
25+
// Persist in UTC, per the `dismissed_announcements.dismissedAt` contract.
26+
await _datasource.upsert(id.name, DateTime.now().toUtc());
27+
}
28+
}
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/models/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/models/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+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import 'package:bb_mobile/core/failures/failure.dart';
2+
3+
/// The announcements feature's sealed failure family (Flutter-free).
4+
///
5+
/// Its `toTranslated(BuildContext)` lives in the presentation layer
6+
/// (`presentation/announcements_failure_l10n.dart`), never here.
7+
sealed class AnnouncementsFailure extends Failure {
8+
const AnnouncementsFailure([super.logMessage]);
9+
}
10+
11+
/// A dismissal could not be read from or written to persistent storage.
12+
final class AnnouncementStorageFailure extends AnnouncementsFailure {
13+
const AnnouncementStorageFailure([super.logMessage]);
14+
}
15+
16+
/// Catch-all for anything not modeled above. The UI renders a generic
17+
/// localized message for this — never the raw [logMessage].
18+
final class AnnouncementUnexpectedFailure extends AnnouncementsFailure {
19+
const AnnouncementUnexpectedFailure([super.logMessage]);
20+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/// A home-screen announcement: a dismissible, tappable nudge shown in the
2+
/// wallet-home carousel (e.g. "Increase privacy with Payjoin").
3+
///
4+
/// Announcements are defined in code (compile-time), not persisted — only the
5+
/// per-user *dismissal* fact is stored (see `DismissedAnnouncements` table).
6+
/// The user-facing title/description are NOT held here: they map to
7+
/// localization keys in the presentation layer so `domain/` stays Flutter-free.
8+
library;
9+
10+
/// Stable identifier for each announcement. The enum *name* is the persistence
11+
/// key (stored in `dismissed_announcements.announcement_id`) and the l10n key
12+
/// prefix, so **never rename or reorder existing values** — only append.
13+
enum AnnouncementId {
14+
/// Shown once the wallet has transaction history and payjoin is disabled,
15+
/// inviting the user to enable payjoin for better on-chain privacy.
16+
payjoinPrivacy,
17+
18+
/// Shown while autoswap is enabled, so the user is aware it's active and can
19+
/// learn what it does.
20+
autoswapActive,
21+
}
22+
23+
/// Visual/semantic tone of an announcement, mapped to theme colors in the UI.
24+
enum AnnouncementTone { info, warning, success }
25+
26+
/// What tapping the announcement's body does.
27+
sealed class AnnouncementAction {
28+
const AnnouncementAction();
29+
}
30+
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.
34+
final class NavigateAction extends AnnouncementAction {
35+
const NavigateAction();
36+
}
37+
38+
/// How re-display works after the user dismisses an announcement.
39+
sealed class DismissPolicy {
40+
const DismissPolicy();
41+
}
42+
43+
/// Once dismissed, never shown again (until its trigger condition itself
44+
/// changes — which is decided by the trigger, not this policy).
45+
final class PermanentDismiss extends DismissPolicy {
46+
const PermanentDismiss();
47+
}
48+
49+
/// Dismissed only temporarily: re-arms (becomes eligible again) once [interval]
50+
/// has elapsed since the dismissal timestamp.
51+
final class SnoozeDismiss extends DismissPolicy {
52+
final Duration interval;
53+
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+
}
63+
}
64+
65+
/// A rich, self-validating announcement definition.
66+
///
67+
/// Invalid instances are impossible to construct: the id is a closed enum, the
68+
/// action and policy are sealed, and [priority] is validated in the
69+
/// constructor. Ordering in the carousel is by ascending [priority].
70+
class Announcement {
71+
final AnnouncementId id;
72+
73+
/// Lower shows first in the carousel. Must be non-negative.
74+
final int priority;
75+
76+
final AnnouncementTone tone;
77+
final AnnouncementAction action;
78+
final DismissPolicy dismissPolicy;
79+
80+
Announcement({
81+
required this.id,
82+
required this.priority,
83+
required this.tone,
84+
required this.action,
85+
required this.dismissPolicy,
86+
}) {
87+
if (priority < 0) {
88+
throw ArgumentError.value(priority, 'priority', 'must be non-negative');
89+
}
90+
}
91+
92+
/// Whether a dismissal recorded at [dismissedAt] still suppresses this
93+
/// announcement as of [now]. Permanent dismissals always suppress; snooze
94+
/// dismissals stop suppressing once the interval has elapsed.
95+
bool isSuppressedBy(DateTime dismissedAt, {required DateTime now}) {
96+
return switch (dismissPolicy) {
97+
PermanentDismiss() => true,
98+
SnoozeDismiss(:final interval) => now.isBefore(dismissedAt.add(interval)),
99+
};
100+
}
101+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
2+
3+
/// The runtime signals a trigger can read to decide whether it fires.
4+
///
5+
/// Extend this (and the gathering in `GetVisibleAnnouncementsUsecase`) as new
6+
/// announcements need new signals.
7+
class AnnouncementSignals {
8+
final bool isPayjoinEnabled;
9+
final bool hasTransactionHistory;
10+
final bool isAutoswapEnabled;
11+
12+
const AnnouncementSignals({
13+
required this.isPayjoinEnabled,
14+
required this.hasTransactionHistory,
15+
required this.isAutoswapEnabled,
16+
});
17+
}
18+
19+
/// A catalog entry: an [Announcement] definition paired with the predicate that
20+
/// decides whether it should appear given the current [AnnouncementSignals].
21+
class AnnouncementCatalogEntry {
22+
final Announcement announcement;
23+
24+
/// Predicate deciding whether this announcement should appear given the
25+
/// current signals. Read through [triggersFor].
26+
final bool Function(AnnouncementSignals signals) trigger;
27+
28+
const AnnouncementCatalogEntry({
29+
required this.announcement,
30+
required this.trigger,
31+
});
32+
33+
bool triggersFor(AnnouncementSignals signals) => trigger(signals);
34+
}
35+
36+
/// The single compile-time registry of every home announcement.
37+
///
38+
/// To add an announcement: append an [AnnouncementId] value, add a catalog
39+
/// entry here with its trigger, and add the title/description l10n mapping in
40+
/// `presentation/announcement_l10n.dart`.
41+
final List<AnnouncementCatalogEntry> announcementCatalog = [
42+
AnnouncementCatalogEntry(
43+
announcement: Announcement(
44+
id: AnnouncementId.payjoinPrivacy,
45+
priority: 0,
46+
tone: AnnouncementTone.info,
47+
action: const NavigateAction(),
48+
dismissPolicy: const PermanentDismiss(),
49+
),
50+
// Show once the wallet has received/transacted (first UTXO or history after
51+
// create/recover) AND payjoin is still off — nudging the privacy upgrade.
52+
trigger: (s) => s.hasTransactionHistory && !s.isPayjoinEnabled,
53+
),
54+
AnnouncementCatalogEntry(
55+
announcement: Announcement(
56+
id: AnnouncementId.autoswapActive,
57+
priority: 1,
58+
tone: AnnouncementTone.success,
59+
action: const NavigateAction(),
60+
dismissPolicy: const PermanentDismiss(),
61+
),
62+
// Show while autoswap is enabled, letting the user learn what it does.
63+
trigger: (s) => s.isAutoswapEnabled,
64+
),
65+
];
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
2+
3+
/// A record that the user dismissed a given announcement at a given time.
4+
///
5+
/// Domain entity handed out by `AnnouncementDismissalRepository`; the wire/DB
6+
/// shape lives in `data/models/` and never crosses the repository boundary.
7+
class AnnouncementDismissal {
8+
final AnnouncementId id;
9+
final DateTime dismissedAt;
10+
11+
AnnouncementDismissal({required this.id, required this.dismissedAt});
12+
}

0 commit comments

Comments
 (0)