Skip to content

Commit 007fe2f

Browse files
committed
refactor(settings): read the payjoin disclaimer flag through a usecase
The disclaimer dialog resolved a datasource from the service locator inside a widget, so the ui layer talked straight to data (AGENTS.md rules #2/#6), and the receive feature reached into settings/ui/widgets for it (rule #1). The flag now travels the normal chain: a PayjoinDisclaimerRepository interface in domain, a SharedPreferences implementation in data, two usecases, and SettingsCubit holding the state. One boolean does not justify a datasource forwarding two calls to a repository forwarding them back, so it collapses into the repository. The dialog becomes passive and moves to core/widgets, which also drops the cross-feature import: both call sites show it, then record it — never the other way round.
1 parent 2c1642d commit 007fe2f

11 files changed

Lines changed: 136 additions & 62 deletions

lib/features/settings/ui/widgets/payjoin_disclaimer_dialog.dart renamed to lib/core/widgets/dialog/payjoin_disclaimer_dialog.dart

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,21 @@
11
import 'package:bb_mobile/core/utils/build_context_x.dart';
2-
import 'package:bb_mobile/features/settings/data/payjoin_disclaimer_datasource.dart';
3-
import 'package:bb_mobile/locator.dart';
42
import 'package:bull_ui/bull_ui.dart';
53
import 'package:flutter/material.dart';
64

7-
/// The payjoin disclaimer pop-up (product decision 2026-07-25): shown ONCE,
8-
/// automatically, the first time the user turns payjoin on — from either the
9-
/// settings screen or the receive-screen toggle — and afterwards only on
10-
/// demand via the "Payjoin disclaimer" row on the payjoin settings screen.
5+
/// The payjoin privacy disclosure, as a modal (product decision 2026-07-25).
6+
///
7+
/// Passive on purpose: it renders and pops, nothing else. Whether it has
8+
/// already been shown is state owned by `SettingsCubit`, and the call sites
9+
/// (the payjoin settings screen and the receive-screen toggle) do the "show
10+
/// once, then record" sequencing — a widget must not reach a repository or a
11+
/// datasource itself (AGENTS.md rules #2/#6).
1112
abstract final class PayjoinDisclaimerDialog {
12-
/// Shows the disclaimer unconditionally (the settings-row entry point).
1313
static Future<void> show(BuildContext context) {
1414
return BullDialog.show<void>(
1515
context: context,
1616
builder: (dialogContext) => _PayjoinDisclaimerBody(dialogContext),
1717
);
1818
}
19-
20-
/// Shows the disclaimer only if it has never been shown before, and marks
21-
/// it shown. Call after the user turns payjoin ON.
22-
static Future<void> showIfNeverShown(BuildContext context) async {
23-
final datasource = locator<PayjoinDisclaimerDatasource>();
24-
if (await datasource.readDisclaimerShown()) return;
25-
// Mark shown only once the dialog was actually displayed — writing
26-
// first would permanently skip the one-time disclosure if the context
27-
// is unmounted by the time the async read resolves.
28-
if (!context.mounted) return;
29-
await show(context);
30-
await datasource.writeDisclaimerShown();
31-
}
3219
}
3320

3421
class _PayjoinDisclaimerBody extends StatelessWidget {

lib/features/receive/ui/widgets/receive_payjoin_toggle_button.dart

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import 'package:bb_mobile/core/themes/app_theme.dart';
22
import 'package:bb_mobile/core/utils/build_context_x.dart';
3+
import 'package:bb_mobile/core/widgets/dialog/payjoin_disclaimer_dialog.dart';
34
import 'package:bb_mobile/core/widgets/switch/bb_switch.dart';
45
import 'package:bb_mobile/core/widgets/text/text.dart';
56
import 'package:bb_mobile/core/widgets/tiles/bordered_tappable_tile.dart';
67
import 'package:bb_mobile/features/receive/presentation/bloc/receive_bloc.dart';
8+
import 'package:bb_mobile/features/settings/presentation/bloc/settings_cubit.dart';
79
import 'package:bb_mobile/features/settings/ui/settings_router.dart';
8-
import 'package:bb_mobile/features/settings/ui/widgets/payjoin_disclaimer_dialog.dart';
910
import 'package:flutter/material.dart';
1011
import 'package:flutter_bloc/flutter_bloc.dart';
1112
import 'package:go_router/go_router.dart';
@@ -60,14 +61,19 @@ class ReceivePayjoinToggleTile extends StatelessWidget {
6061
BBSwitch(
6162
value: enabled,
6263
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
63-
onChanged: (value) {
64+
onChanged: (value) async {
65+
final settings = context.read<SettingsCubit>();
6466
context.read<ReceiveBloc>().add(
6567
ReceiveEvent.receivePayjoinToggled(value),
6668
);
67-
// One-time disclaimer, only when turning ON.
68-
if (value) {
69-
PayjoinDisclaimerDialog.showIfNeverShown(context);
69+
// One-time disclaimer, only when turning ON. Recorded after
70+
// the dialog was actually dismissed, so an interrupted show
71+
// never consumes the disclosure.
72+
if (!value || settings.state.payjoinDisclaimerShown == true) {
73+
return;
7074
}
75+
await PayjoinDisclaimerDialog.show(context);
76+
await settings.markPayjoinDisclaimerShown();
7177
},
7278
),
7379
],

lib/features/settings/data/payjoin_disclaimer_datasource.dart

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import 'package:bb_mobile/features/settings/domain/repositories/payjoin_disclaimer_repository.dart';
2+
import 'package:shared_preferences/shared_preferences.dart';
3+
4+
/// [SharedPreferences]-backed implementation: the only file that knows the pref
5+
/// key name.
6+
///
7+
/// No separate datasource on purpose — one boolean flag is trivial CRUD, and a
8+
/// datasource forwarding two calls to a repository forwarding them back is the
9+
/// noise AGENTS.md rule #6 warns about. The flag is presentation state (has the
10+
/// user been interrupted yet), so it deliberately does NOT live in the settings
11+
/// Drift table alongside the payjoin settings themselves.
12+
class PayjoinDisclaimerRepositoryImpl implements PayjoinDisclaimerRepository {
13+
static const _shownKey = 'payjoin_disclaimer_shown';
14+
15+
@override
16+
Future<bool> hasBeenShown() async {
17+
final prefs = await SharedPreferences.getInstance();
18+
return prefs.getBool(_shownKey) ?? false;
19+
}
20+
21+
@override
22+
Future<void> markShown() async {
23+
final prefs = await SharedPreferences.getInstance();
24+
await prefs.setBool(_shownKey, true);
25+
}
26+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/// Whether the one-time payjoin disclaimer has already been shown.
2+
///
3+
/// The disclaimer is a privacy disclosure presented when the user turns payjoin
4+
/// on (see PAYJOIN_FEATURES_PLAN D10/D11): it must interrupt exactly once, and
5+
/// stay re-openable on demand from the payjoin settings screen. That single bit
6+
/// is all this repository owns.
7+
abstract interface class PayjoinDisclaimerRepository {
8+
Future<bool> hasBeenShown();
9+
10+
Future<void> markShown();
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import 'package:bb_mobile/features/settings/domain/repositories/payjoin_disclaimer_repository.dart';
2+
3+
class GetPayjoinDisclaimerShownUsecase {
4+
final PayjoinDisclaimerRepository _payjoinDisclaimerRepository;
5+
6+
GetPayjoinDisclaimerShownUsecase({
7+
required this._payjoinDisclaimerRepository,
8+
});
9+
10+
Future<bool> execute() => _payjoinDisclaimerRepository.hasBeenShown();
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import 'package:bb_mobile/features/settings/domain/repositories/payjoin_disclaimer_repository.dart';
2+
3+
class MarkPayjoinDisclaimerShownUsecase {
4+
final PayjoinDisclaimerRepository _payjoinDisclaimerRepository;
5+
6+
MarkPayjoinDisclaimerShownUsecase({
7+
required this._payjoinDisclaimerRepository,
8+
});
9+
10+
Future<void> execute() => _payjoinDisclaimerRepository.markShown();
11+
}

lib/features/settings/presentation/bloc/settings_cubit.dart

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
44
import 'package:bb_mobile/core/storage/migrations/005_hive_to_sqlite/get_old_seeds_usecase.dart';
55
import 'package:bb_mobile/core/utils/constants.dart';
66
import 'package:bb_mobile/core/utils/logger.dart';
7+
import 'package:bb_mobile/features/settings/domain/usecases/get_payjoin_disclaimer_shown_usecase.dart';
8+
import 'package:bb_mobile/features/settings/domain/usecases/mark_payjoin_disclaimer_shown_usecase.dart';
79
import 'package:bb_mobile/features/settings/domain/usecases/set_bitcoin_unit_usecase.dart';
810
import 'package:bb_mobile/features/settings/domain/usecases/set_error_reporting_usecase.dart';
911
import 'package:bb_mobile/features/settings/domain/usecases/set_currency_usecase.dart';
@@ -43,6 +45,8 @@ class SettingsCubit extends Cubit<SettingsState> {
4345
required this._setPayjoinEnabledUsecase,
4446
required this._setPayjoinMinAmountUsecase,
4547
required this._setPayjoinExpireAfterSecUsecase,
48+
required this._getPayjoinDisclaimerShownUsecase,
49+
required this._markPayjoinDisclaimerShownUsecase,
4650
}) : super(const SettingsState());
4751

4852
final SetEnvironmentUsecase _setEnvironmentUsecase;
@@ -61,20 +65,36 @@ class SettingsCubit extends Cubit<SettingsState> {
6165
final SetPayjoinEnabledUsecase _setPayjoinEnabledUsecase;
6266
final SetPayjoinMinAmountUsecase _setPayjoinMinAmountUsecase;
6367
final SetPayjoinExpireAfterSecUsecase _setPayjoinExpireAfterSecUsecase;
68+
final GetPayjoinDisclaimerShownUsecase _getPayjoinDisclaimerShownUsecase;
69+
final MarkPayjoinDisclaimerShownUsecase _markPayjoinDisclaimerShownUsecase;
6470

6571
Future<void> init() async {
66-
final (storedSettings, appInfo) = await (
72+
final (storedSettings, appInfo, payjoinDisclaimerShown) = await (
6773
_getSettingsUsecase.execute(),
6874
PackageInfo.fromPlatform(),
75+
_getPayjoinDisclaimerShownUsecase.execute(),
6976
).wait;
7077
final appVersion = '${appInfo.version}+${appInfo.buildNumber}';
7178

7279
emit(
73-
state.copyWith(storedSettings: storedSettings, appVersion: appVersion),
80+
state.copyWith(
81+
storedSettings: storedSettings,
82+
appVersion: appVersion,
83+
payjoinDisclaimerShown: payjoinDisclaimerShown,
84+
),
7485
);
7586
await checkHasLegacySeeds();
7687
}
7788

89+
/// Records that the one-time payjoin disclaimer has been presented. Called by
90+
/// the UI *after* the dialog was actually displayed and dismissed — writing
91+
/// it earlier would permanently skip a privacy disclosure if the widget went
92+
/// away in between.
93+
Future<void> markPayjoinDisclaimerShown() async {
94+
await _markPayjoinDisclaimerShownUsecase.execute();
95+
emit(state.copyWith(payjoinDisclaimerShown: true));
96+
}
97+
7898
Future<void> toggleTestnetMode(bool active) async {
7999
final settings = state.storedSettings;
80100
log.config(

lib/features/settings/presentation/bloc/settings_state.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ sealed class SettingsState with _$SettingsState {
66
SettingsEntity? storedSettings,
77
String? appVersion,
88
bool? hasLegacySeeds,
9+
10+
/// Whether the one-time payjoin disclaimer has already been presented.
11+
/// Null until [SettingsCubit.init] has read it: treated as "not shown yet"
12+
/// by callers, so a disclosure is never silently skipped.
13+
bool? payjoinDisclaimerShown,
914
}) = _SettingsState;
1015
const SettingsState._();
1116

lib/features/settings/settings_locator.dart

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import 'package:bb_mobile/features/settings/data/payjoin_disclaimer_datasource.dart';
21
import 'package:bb_mobile/core/ark/usecases/revoke_ark_usecase.dart';
32
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
43
import 'package:bb_mobile/core/settings/domain/get_settings_usecase.dart';
54
import 'package:bb_mobile/core/storage/migrations/005_hive_to_sqlite/get_old_seeds_usecase.dart';
5+
import 'package:bb_mobile/features/settings/data/payjoin_disclaimer_repository_impl.dart';
6+
import 'package:bb_mobile/features/settings/domain/repositories/payjoin_disclaimer_repository.dart';
7+
import 'package:bb_mobile/features/settings/domain/usecases/get_payjoin_disclaimer_shown_usecase.dart';
8+
import 'package:bb_mobile/features/settings/domain/usecases/mark_payjoin_disclaimer_shown_usecase.dart';
69
import 'package:bb_mobile/features/settings/domain/usecases/set_bitcoin_unit_usecase.dart';
710
import 'package:bb_mobile/features/settings/domain/usecases/set_error_reporting_usecase.dart';
811
import 'package:bb_mobile/features/settings/domain/usecases/set_currency_usecase.dart';
@@ -81,8 +84,18 @@ class SettingsLocator {
8184
),
8285
);
8386

84-
locator.registerLazySingleton<PayjoinDisclaimerDatasource>(
85-
PayjoinDisclaimerDatasourceImpl.new,
87+
locator.registerLazySingleton<PayjoinDisclaimerRepository>(
88+
PayjoinDisclaimerRepositoryImpl.new,
89+
);
90+
locator.registerFactory<GetPayjoinDisclaimerShownUsecase>(
91+
() => GetPayjoinDisclaimerShownUsecase(
92+
payjoinDisclaimerRepository: locator<PayjoinDisclaimerRepository>(),
93+
),
94+
);
95+
locator.registerFactory<MarkPayjoinDisclaimerShownUsecase>(
96+
() => MarkPayjoinDisclaimerShownUsecase(
97+
payjoinDisclaimerRepository: locator<PayjoinDisclaimerRepository>(),
98+
),
8699
);
87100
locator.registerFactory<SetPayjoinEnabledUsecase>(
88101
() => SetPayjoinEnabledUsecase(
@@ -121,6 +134,10 @@ class SettingsLocator {
121134
setPayjoinMinAmountUsecase: locator<SetPayjoinMinAmountUsecase>(),
122135
setPayjoinExpireAfterSecUsecase:
123136
locator<SetPayjoinExpireAfterSecUsecase>(),
137+
getPayjoinDisclaimerShownUsecase:
138+
locator<GetPayjoinDisclaimerShownUsecase>(),
139+
markPayjoinDisclaimerShownUsecase:
140+
locator<MarkPayjoinDisclaimerShownUsecase>(),
124141
),
125142
);
126143
}

0 commit comments

Comments
 (0)