Skip to content

Commit e1968e7

Browse files
committed
feat: payjoin toggle on send confirm so the sender can opt out
1 parent beca4be commit e1968e7

5 files changed

Lines changed: 57 additions & 20 deletions

File tree

lib/core/screens/send_confirm_screen.dart

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:bb_mobile/core/widgets/switch/bb_switch.dart';
12
import 'package:bb_mobile/core/errors/send_errors.dart';
23
import 'package:bb_mobile/core/widgets/address_viewer.dart';
34
import 'package:bb_mobile/core/swaps/domain/entity/swap.dart';
@@ -117,7 +118,8 @@ class CommonOnchainSendInfoSection extends StatelessWidget {
117118
required this._selectedFeeOptionTitle,
118119
this._onFeePriorityTap,
119120
this._isToSelf = false,
120-
this._checkedStatusLabel,
121+
this._payjoinToggleValue,
122+
this._onPayjoinToggleChanged,
121123
this._note = '',
122124
});
123125
final String _sendWalletLabel;
@@ -129,8 +131,12 @@ class CommonOnchainSendInfoSection extends StatelessWidget {
129131
final VoidCallback? _onFeePriorityTap;
130132
final bool _isToSelf;
131133

132-
/// Optional checked capability/status row supplied by the owning feature.
133-
final String? _checkedStatusLabel;
134+
/// Payjoin toggle row: shown when [_payjoinToggleValue] is non-null (i.e.
135+
/// a payjoin is available for this send), letting the sender choose NOT to
136+
/// payjoin. The value mirrors the feature's will-attempt state so the
137+
/// switch and the sign path can never disagree.
138+
final bool? _payjoinToggleValue;
139+
final ValueChanged<bool>? _onPayjoinToggleChanged;
134140
final String _note;
135141
Widget _divider(BuildContext context) {
136142
return Container(height: 1, color: context.appColors.secondaryFixedDim);
@@ -176,16 +182,15 @@ class CommonOnchainSendInfoSection extends StatelessWidget {
176182
),
177183
),
178184
],
179-
if (_checkedStatusLabel != null) ...[
185+
if (_payjoinToggleValue != null) ...[
180186
_divider(context),
181187
CommonInfoRow(
182-
title: _checkedStatusLabel,
188+
title: context.loc.sendPayjoinLabel,
183189
details: Align(
184190
alignment: Alignment.centerRight,
185-
child: Icon(
186-
Icons.check,
187-
color: context.appColors.secondary,
188-
size: 20,
191+
child: BBSwitch(
192+
value: _payjoinToggleValue,
193+
onChanged: _onPayjoinToggleChanged,
189194
),
190195
),
191196
),

lib/features/send/presentation/bloc/send_cubit.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1895,6 +1895,14 @@ class SendCubit extends Cubit<SendState>
18951895
}
18961896
}
18971897

1898+
/// The sender's choice on the confirm screen: attempt the payjoin or send
1899+
/// a plain transaction. Ignored once signing has started — the decision is
1900+
/// consumed by [signTransaction]'s payjoin branch.
1901+
void togglePayjoin(bool attempt) {
1902+
if (state.signingTransaction || state.txId != null) return;
1903+
emit(state.copyWith(payjoinOptedOut: !attempt));
1904+
}
1905+
18981906
Future<void> signTransaction() async {
18991907
try {
19001908
emit(state.copyWith(signingTransaction: true));

lib/features/send/presentation/bloc/send_state.dart

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ abstract class SendState with _$SendState {
7474
// fetched settings at least once, no payjoin is attempted. Mirrors
7575
// SettingsEntity.isPayjoinEnabled.
7676
@Default(false) bool payjoinGloballyEnabled,
77+
// The sender's per-send opt-out: payjoin can be available for this send
78+
// (see [isPayjoinAvailable]) and still deliberately not attempted. Reset
79+
// is not needed — the cubit lives per send flow.
80+
@Default(false) bool payjoinOptedOut,
7781
@Default('') String amount,
7882
int? confirmedAmountSat,
7983
BitcoinUnit? bitcoinUnit,
@@ -176,25 +180,30 @@ abstract class SendState with _$SendState {
176180
/// Whether we have a valid payment request
177181
bool get hasValidPaymentRequest => paymentRequest != null;
178182

179-
/// Single source of truth for whether a payjoin will actually be attempted
180-
/// for this send — same pattern as [Payjoin.canManuallyBroadcastOriginal],
181-
/// which unifies a button's visibility and its action guard. Used BOTH to
182-
/// gate `signTransaction`'s payjoin branch and to show the "a payjoin will
183-
/// be attempted" indicator on the confirm screen, so the two can never
184-
/// disagree.
183+
/// Whether a payjoin is structurally possible for this send: the setting
184+
/// is on, the wallet signs locally, and the recipient's BIP21 advertises a
185+
/// pj= endpoint. Drives whether the confirm screen offers the payjoin
186+
/// toggle at all — [willAttemptPayjoin] adds the sender's choice on top.
185187
///
186188
/// Gated on [Wallet.signsLocally]: a hardware/remote-signer wallet
187189
/// (Ledger/BitBox) never reaches `signTransaction`'s payjoin branch (the
188190
/// confirm screen swaps in a device-specific sign button for those
189-
/// wallets instead), so without this check the indicator could promise a
191+
/// wallets instead), so without this check the toggle could promise a
190192
/// payjoin that structurally can never happen for that wallet class.
191-
bool get willAttemptPayjoin =>
193+
bool get isPayjoinAvailable =>
192194
payjoinGloballyEnabled &&
193195
(selectedWallet?.signsLocally ?? false) &&
194196
isToSelf != true &&
195197
paymentRequest is Bip21PaymentRequest &&
196198
(paymentRequest! as Bip21PaymentRequest).pj.isNotEmpty;
197199

200+
/// Single source of truth for whether a payjoin will actually be attempted
201+
/// for this send — same pattern as [Payjoin.canManuallyBroadcastOriginal],
202+
/// which unifies a control's state and its action guard. Used BOTH to
203+
/// gate `signTransaction`'s payjoin branch and as the confirm screen's
204+
/// toggle value, so the two can never disagree.
205+
bool get willAttemptPayjoin => isPayjoinAvailable && !payjoinOptedOut;
206+
198207
String get paymentRequestAddress {
199208
if (paymentRequest == null) {
200209
return copiedRawPaymentRequest.isNotEmpty

lib/features/send/ui/screens/send_screen.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,9 @@ class _OnchainTransactionReview extends StatelessWidget {
10061006
(SendCubit cubit) => cubit.state.isToSelf == true,
10071007
);
10081008
final label = context.select((SendCubit cubit) => cubit.state.label);
1009+
final isPayjoinAvailable = context.select(
1010+
(SendCubit cubit) => cubit.state.isPayjoinAvailable,
1011+
);
10091012
final willAttemptPayjoin = context.select(
10101013
(SendCubit cubit) => cubit.state.willAttemptPayjoin,
10111014
);
@@ -1021,9 +1024,9 @@ class _OnchainTransactionReview extends StatelessWidget {
10211024
absoluteFees: formattedAbsoluteFees,
10221025
selectedFeeOptionTitle: selectedFeeOption.title(),
10231026
isToSelf: isToSelf,
1024-
checkedStatusLabel: willAttemptPayjoin
1025-
? context.loc.sendPayjoinLabel
1026-
: null,
1027+
payjoinToggleValue: isPayjoinAvailable ? willAttemptPayjoin : null,
1028+
onPayjoinToggleChanged: (attempt) =>
1029+
context.read<SendCubit>().togglePayjoin(attempt),
10271030
note: label,
10281031
onFeePriorityTap: hasFinalizedTx
10291032
? null

test/features/send/presentation/bloc/send_state_test.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,18 @@ void main() {
369369
)
370370
as Bip21PaymentRequest;
371371

372+
test('the sender opt-out defeats an otherwise-available payjoin, and '
373+
'isPayjoinAvailable stays true so the toggle keeps rendering', () {
374+
final state = SendState(
375+
paymentRequest: bip21WithPj(),
376+
payjoinGloballyEnabled: true,
377+
selectedWallet: bitcoinWallet(),
378+
payjoinOptedOut: true,
379+
);
380+
expect(state.isPayjoinAvailable, isTrue);
381+
expect(state.willAttemptPayjoin, isFalse);
382+
});
383+
372384
test('false when payjoin is disabled globally, even with a pj= URI', () {
373385
final state = SendState(
374386
paymentRequest: bip21WithPj(),

0 commit comments

Comments
 (0)