Skip to content

Commit 545f724

Browse files
committed
feat(announcements): require app update after swap 418
1 parent f9605b7 commit 545f724

25 files changed

Lines changed: 384 additions & 27 deletions

FEATURES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ graph TB
6262
ADDRESS_MGMT --> LABELS
6363
ALL_SEED_VIEW --> APP_UNLOCK
6464
ANNOUNCEMENTS --> SETTINGS
65+
ANNOUNCEMENTS --> SWAPS
6566
APP_STARTUP --> WALLETS
6667
BIP85 --> SECRETS
6768
BIP85 --> SETTINGS

lib/features/announcements/announcements_locator.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import 'package:bb_mobile/features/announcements/data/announcement_dismissal_rep
33
import 'package:bb_mobile/features/announcements/data/datasources/announcement_dismissal_datasource.dart';
44
import 'package:bb_mobile/features/announcements/domain/usecases/dismiss_announcement_usecase.dart';
55
import 'package:bb_mobile/features/announcements/domain/usecases/get_visible_announcements_usecase.dart';
6+
import 'package:bb_mobile/features/announcements/domain/usecases/watch_app_update_announcement_usecase.dart';
67
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';
78
import 'package:bb_mobile/features/announcements/presentation/announcements_cubit.dart';
89
import 'package:get_it/get_it.dart';
10+
import 'package:bb_mobile/features/swap/public/swap_facade.dart';
911

1012
class AnnouncementsLocator {
1113
static void setup(GetIt locator) {
@@ -22,9 +24,13 @@ class AnnouncementsLocator {
2224
// Use-cases
2325
locator.registerFactory<GetVisibleAnnouncementsUsecase>(
2426
() => GetVisibleAnnouncementsUsecase(
25-
dismissalRepository: locator<AnnouncementDismissalRepository>(),
27+
locator<AnnouncementDismissalRepository>(),
28+
locator<SwapFacade>(),
2629
),
2730
);
31+
locator.registerFactory<WatchAppUpdateAnnouncementUsecase>(
32+
() => WatchAppUpdateAnnouncementUsecase(locator<SwapFacade>()),
33+
);
2834
locator.registerFactory<DismissAnnouncementUsecase>(
2935
() => DismissAnnouncementUsecase(
3036
dismissalRepository: locator<AnnouncementDismissalRepository>(),
@@ -37,6 +43,8 @@ class AnnouncementsLocator {
3743
getVisibleAnnouncementsUsecase:
3844
locator<GetVisibleAnnouncementsUsecase>(),
3945
dismissAnnouncementUsecase: locator<DismissAnnouncementUsecase>(),
46+
watchAppUpdateAnnouncementUsecase:
47+
locator<WatchAppUpdateAnnouncementUsecase>(),
4048
),
4149
);
4250
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ enum AnnouncementId {
1414
/// Shown once the wallet has transaction history and payjoin is disabled,
1515
/// inviting the user to enable payjoin for better on-chain privacy.
1616
payjoinPrivacy,
17-
17+
appUpdateRequired,
1818
}
1919

2020
/// Visual/semantic tone of an announcement, mapped to theme colors in the UI.
@@ -32,6 +32,10 @@ final class NavigateAction extends AnnouncementAction {
3232
const NavigateAction();
3333
}
3434

35+
final class NoAction extends AnnouncementAction {
36+
const NoAction();
37+
}
38+
3539
/// How re-display works after the user dismisses an announcement.
3640
sealed class DismissPolicy {
3741
const DismissPolicy();

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import 'package:bb_mobile/features/announcements/domain/entities/announcement.da
55
/// Extend this (and the gathering in `GetVisibleAnnouncementsUsecase`) as new
66
/// announcements need new signals.
77
class AnnouncementSignals {
8-
const AnnouncementSignals();
8+
final bool isAppUpdateRequired;
9+
10+
const AnnouncementSignals({required this.isAppUpdateRequired});
911
}
1012

1113
/// A catalog entry: an [Announcement] definition paired with the predicate that
@@ -31,7 +33,15 @@ class AnnouncementCatalogEntry {
3133
/// entry here with its trigger, and add the title/description l10n mapping in
3234
/// `presentation/announcement_l10n.dart`.
3335
///
34-
/// Currently EMPTY on purpose. The payjoin-privacy nudge was removed
35-
/// (product decision 2026-07-25): payjoin education lives in the enable-time
36-
/// disclaimer and the payjoin settings screen, not on home.
37-
final List<AnnouncementCatalogEntry> announcementCatalog = [];
36+
final List<AnnouncementCatalogEntry> announcementCatalog = [
37+
AnnouncementCatalogEntry(
38+
announcement: Announcement(
39+
id: AnnouncementId.appUpdateRequired,
40+
priority: 0,
41+
tone: AnnouncementTone.warning,
42+
action: const NoAction(),
43+
dismissPolicy: SnoozeDismiss(const Duration(days: 1)),
44+
),
45+
trigger: (signals) => signals.isAppUpdateRequired,
46+
),
47+
];

lib/features/announcements/domain/usecases/get_visible_announcements_usecase.dart

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:bb_mobile/features/announcements/domain/announcements_failure.da
33
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
44
import 'package:bb_mobile/features/announcements/domain/entities/announcement_catalog.dart';
55
import 'package:bb_mobile/features/announcements/domain/repositories/announcement_dismissal_repository.dart';
6+
import 'package:bb_mobile/features/swap/public/swap_facade.dart';
67

78
/// Orchestrates which announcements are currently visible on the home carousel.
89
///
@@ -11,18 +12,19 @@ import 'package:bb_mobile/features/announcements/domain/repositories/announcemen
1112
/// dismissed (respecting the per-announcement dismiss policy), and returns
1213
/// the survivors ordered by ascending priority. All decision *rules* live on
1314
/// the entities / catalog; this use-case only wires signals to them. (The
14-
/// catalog is currently empty — see its doc comment — so this returns an
15-
/// empty list until a future announcement is added.)
1615
class GetVisibleAnnouncementsUsecase {
1716
final AnnouncementDismissalRepository _dismissalRepository;
17+
final SwapFacade _swapFacade;
1818

19-
GetVisibleAnnouncementsUsecase({required this._dismissalRepository});
19+
GetVisibleAnnouncementsUsecase(this._dismissalRepository, this._swapFacade);
2020

2121
Future<Result<List<Announcement>, AnnouncementsFailure>> execute() async {
2222
try {
2323
final dismissals = await _dismissalRepository.getDismissals();
2424

25-
const signals = AnnouncementSignals();
25+
final signals = AnnouncementSignals(
26+
isAppUpdateRequired: _swapFacade.isAppUpdateRequired,
27+
);
2628
final dismissedAtById = {for (final d in dismissals) d.id: d.dismissedAt};
2729
final now = DateTime.now().toUtc();
2830

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import 'package:bb_mobile/features/swap/public/swap_facade.dart';
2+
3+
class WatchAppUpdateAnnouncementUsecase {
4+
final SwapFacade _swapFacade;
5+
6+
WatchAppUpdateAnnouncementUsecase(this._swapFacade);
7+
8+
Stream<bool> execute() => _swapFacade.watchAppUpdateRequired();
9+
}

lib/features/announcements/presentation/announcement_l10n.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ import 'package:flutter/widgets.dart';
99
extension AnnouncementL10n on Announcement {
1010
String title(BuildContext context) => switch (id) {
1111
AnnouncementId.payjoinPrivacy => context.loc.announcementPayjoinTitle,
12+
AnnouncementId.appUpdateRequired =>
13+
context.loc.announcementAppUpdateRequiredTitle,
1214
};
1315

1416
String description(BuildContext context) => switch (id) {
1517
AnnouncementId.payjoinPrivacy => context.loc.announcementPayjoinDescription,
18+
AnnouncementId.appUpdateRequired =>
19+
context.loc.announcementAppUpdateRequiredDescription,
1620
};
1721
}

lib/features/announcements/presentation/announcements_cubit.dart

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import 'dart:async';
2+
13
import 'package:bb_mobile/features/announcements/domain/announcements_failure.dart';
24
import 'package:bb_mobile/features/announcements/domain/usecases/dismiss_announcement_usecase.dart';
35
import 'package:bb_mobile/features/announcements/domain/entities/announcement.dart';
46
import 'package:bb_mobile/features/announcements/domain/usecases/get_visible_announcements_usecase.dart';
7+
import 'package:bb_mobile/features/announcements/domain/usecases/watch_app_update_announcement_usecase.dart';
58
import 'package:flutter_bloc/flutter_bloc.dart';
69
import 'package:freezed_annotation/freezed_annotation.dart';
710

@@ -17,15 +20,22 @@ part 'announcements_state.dart';
1720
class AnnouncementsCubit extends Cubit<AnnouncementsState> {
1821
final GetVisibleAnnouncementsUsecase _getVisibleAnnouncementsUsecase;
1922
final DismissAnnouncementUsecase _dismissAnnouncementUsecase;
23+
late final StreamSubscription<bool> _appUpdateRequiredSubscription;
2024

2125
bool _refreshing = false;
2226
bool _refreshQueued = false;
2327

24-
// No signal subscriptions: the catalog is currently empty.
2528
AnnouncementsCubit({
2629
required this._getVisibleAnnouncementsUsecase,
2730
required this._dismissAnnouncementUsecase,
28-
}) : super(const AnnouncementsState());
31+
required WatchAppUpdateAnnouncementUsecase
32+
watchAppUpdateAnnouncementUsecase,
33+
}) : super(const AnnouncementsState()) {
34+
_appUpdateRequiredSubscription = watchAppUpdateAnnouncementUsecase
35+
.execute()
36+
.where((isRequired) => isRequired)
37+
.listen((_) => unawaited(refresh()));
38+
}
2939

3040
/// (Re)loads the visible announcements. Called on mount and whenever a
3141
/// trigger signal changes.
@@ -65,4 +75,10 @@ class AnnouncementsCubit extends Cubit<AnnouncementsState> {
6575
(failure) async => emit(state.copyWith(failure: failure)),
6676
);
6777
}
78+
79+
@override
80+
Future<void> close() async {
81+
await _appUpdateRequiredSubscription.cancel();
82+
return super.close();
83+
}
6884
}

lib/features/announcements/ui/announcement_navigation.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,8 @@ import 'package:bb_mobile/features/settings/public/settings_facade.dart';
99
extension AnnouncementNavigation on Announcement {
1010
SettingsRoute get route => switch (id) {
1111
AnnouncementId.payjoinPrivacy => SettingsRoute.payjoinSettings,
12+
AnnouncementId.appUpdateRequired => throw UnsupportedError(
13+
'The app update announcement does not navigate',
14+
),
1215
};
1316
}

lib/features/announcements/ui/widgets/announcement_carousel.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class _CarouselBodyState extends State<_CarouselBody> {
7272
/// card. Scales with the user's text size so larger accessibility settings
7373
/// never overflow.
7474
static const double _baseCardHeight = 90;
75+
static const double _longCardHeight = 170;
7576

7677
/// Extra height reserved for the page-indicator dots strip, only when more
7778
/// than one announcement is shown — reserving it for a single card renders
@@ -97,6 +98,8 @@ class _CarouselBodyState extends State<_CarouselBody> {
9798
switch (announcement.action) {
9899
case NavigateAction():
99100
context.pushNamed(announcement.route.name);
101+
case NoAction():
102+
break;
100103
}
101104
}
102105

@@ -105,7 +108,9 @@ class _CarouselBodyState extends State<_CarouselBody> {
105108
final choice = await AnnouncementDismissDialog.show(context);
106109
switch (choice) {
107110
case AnnouncementDismissChoice.read:
108-
if (mounted) _onTap(announcement);
111+
if (mounted && announcement.action is NavigateAction) {
112+
_onTap(announcement);
113+
}
109114
case AnnouncementDismissChoice.dismiss:
110115
await cubit.dismiss(announcement.id);
111116
case null:
@@ -123,8 +128,14 @@ class _CarouselBodyState extends State<_CarouselBody> {
123128
// Adapt to the user's text-scale setting so the card grows with larger
124129
// accessibility font sizes instead of overflowing.
125130
final textScale = MediaQuery.textScalerOf(context).scale(1);
131+
final baseHeight =
132+
announcements.any(
133+
(announcement) => announcement.id == AnnouncementId.appUpdateRequired,
134+
)
135+
? _longCardHeight
136+
: _baseCardHeight;
126137
final cardHeight =
127-
(_baseCardHeight + (showDots ? _dotsStripHeight : 0)) * textScale;
138+
(baseHeight + (showDots ? _dotsStripHeight : 0)) * textScale;
128139

129140
return SizedBox(
130141
height: cardHeight,

0 commit comments

Comments
 (0)