Skip to content
Draft
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
38 changes: 38 additions & 0 deletions lib/core/widgets/fees/fee_selection_label.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
import 'package:bb_mobile/core/utils/amount_formatting.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:flutter/widgets.dart';

/// Value shown on the "Fee Priority" row of the exchange confirmation screens
/// (sell, pay) for the committed selection. Presets read as their tier name; a
/// custom fee reads as the value that was typed, because "Custom Fee" on its
/// own hides the only number that tells the tiers apart.
///
/// [fastestLabel] comes from the caller: each flow ships its own translated
/// "Fastest" string, while the remaining tiers have no per-flow copy and share
/// the send keys.
String feeSelectionRowLabel(
BuildContext context, {
required FeeSelection selection,
required NetworkFee? customFee,
required String fastestLabel,
}) => switch (selection) {
FeeSelection.fastest => fastestLabel,
FeeSelection.economic => context.loc.sendEconomyFee,
FeeSelection.slow => context.loc.feePrioritySlow,
FeeSelection.custom => switch (customFee) {
RelativeFee(:final satPerKwu) =>
'${_formattedRate(satPerKwu / 250.0)} ${context.loc.sendSatsPerVB}',
AbsoluteFee(:final sats) =>
'${FormatAmount.sats(sats)} ${context.loc.sendSats}',
// Custom is selected but nothing has been typed yet — only reachable
// while the modal is open, since dismissal rolls an empty field back.
null => context.loc.sendCustomFee,
},
};

/// Whole rates lose the decimals: "3 sats/vB" rather than "3.00 sats/vB".
String _formattedRate(double satPerVbyte) =>
satPerVbyte == satPerVbyte.roundToDouble()
? satPerVbyte.toStringAsFixed(0)
: satPerVbyte.toStringAsFixed(2);
5 changes: 5 additions & 0 deletions lib/features/pay/pay_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import 'package:bb_mobile/features/send/domain/usecases/calculate_liquid_absolut

import 'package:bb_mobile/core/wallet/domain/usecases/prepare_bitcoin_send_usecase.dart';
import 'package:bb_mobile/features/send/domain/usecases/prepare_liquid_send_usecase.dart';
import 'package:bb_mobile/features/send/domain/usecases/preview_bitcoin_fee_presets_usecase.dart';
import 'package:bb_mobile/features/send/domain/usecases/preview_bitcoin_fee_usecase.dart';
import 'package:bb_mobile/features/send/domain/usecases/sign_bitcoin_tx_usecase.dart';
import 'package:bb_mobile/features/send/domain/usecases/sign_liquid_tx_usecase.dart';
import 'package:get_it/get_it.dart';
Expand Down Expand Up @@ -76,6 +78,9 @@ class PayLocator {
getAddressAtIndexUsecase: locator<GetAddressAtIndexUsecase>(),
getWalletUtxosUsecase: locator<GetWalletUtxosUsecase>(),
getOrderUsecase: locator<GetOrderUsecase>(),
previewBitcoinFeeUsecase: locator<PreviewBitcoinFeeUsecase>(),
previewBitcoinFeePresetsUsecase:
locator<PreviewBitcoinFeePresetsUsecase>(),
),
);
}
Expand Down
438 changes: 409 additions & 29 deletions lib/features/pay/presentation/pay_bloc.dart

Large diffs are not rendered by default.

36 changes: 32 additions & 4 deletions lib/features/pay/presentation/pay_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ sealed class PayEvent with _$PayEvent {
required OrderBitcoinNetwork network,
}) = PayExternalWalletNetworkSelected;
const factory PayEvent.orderRefreshTimePassed() = PayOrderRefreshTimePassed;
const factory PayEvent.sendPaymentConfirmed({
required FeeSelection feeSelection,
NetworkFee? customFee,
}) = PaySendPaymentConfirmed;
// The fee to pay comes from the committed selection on PayPaymentState, not
// from the event: the modal writes it there and the summary row reads it
// back, so a second copy on the event could only ever disagree (#2521).
const factory PayEvent.sendPaymentConfirmed() = PaySendPaymentConfirmed;
const factory PayEvent.pollOrderStatus() = PayPollOrderStatus;
const factory PayEvent.replaceByFeeChanged({required bool replaceByFee}) =
PayReplaceByFeeChanged;
Expand All @@ -30,4 +30,32 @@ sealed class PayEvent with _$PayEvent {
const factory PayEvent.loadUtxos() = PayLoadUtxos;
const factory PayEvent.updateOrderStatus({required String orderId}) =
PayUpdateOrderStatus;

// ────── shared fee modal (FeeModalActions) ──────
/// A preset tile was picked. Commits the tier and rebuilds the fee estimate.
const factory PayEvent.feeOptionSelected(FeeSelection feeSelection) =
PayFeeOptionSelected;

/// The typed custom fee is committed — triggers the fee recalculation.
const factory PayEvent.customFeeChanged(NetworkFee fee) = PayCustomFeeChanged;

/// Custom-fee field changed: select `custom` without rebuilding yet, and
/// snapshot the prior selection so dismissal can roll it back.
const factory PayEvent.customFeeArmed(NetworkFee fee) = PayCustomFeeArmed;

/// The custom-fee field was cleared — roll the arm back immediately so a
/// stale typed value cannot survive to dismissal.
const factory PayEvent.customFeeDisarmed() = PayCustomFeeDisarmed;

/// The modal was dismissed without picking a preset: commit the armed value
/// when it clears the relay floor, otherwise roll back.
const factory PayEvent.customFeeFinalized() = PayCustomFeeFinalized;

/// Debounced preview build for the typed rate (fills the `custom` slot).
const factory PayEvent.customFeePreviewRequested(NetworkFee fee) =
PayCustomFeePreviewRequested;

/// Preview builds for the three presets, fired on every modal open.
const factory PayEvent.presetFeesPreviewRequested() =
PayPresetFeesPreviewRequested;
}
45 changes: 45 additions & 0 deletions lib/features/pay/presentation/pay_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ sealed class PayState with _$PayState {
@Default([]) List<WalletUtxo> selectedUtxos,
@Default(true) bool replaceByFee,
double? exchangeRateEstimate,
// Bitcoin fee selection (#2521), mirroring SellPaymentState: the payin is
// built at the rate picked in the shared fee modal, not a hardcoded
// Fastest.
FeeOptions? bitcoinFees,
NetworkFee? customFee,
@Default(FeeSelection.fastest) FeeSelection selectedFeeOption,
// Arm/disarm snapshot for the custom-fee field: typing commits `custom` so
// the preset tiles deselect, and dismissal either finalizes the value or
// rolls back to these. Internal to the modal flow; the UI must not read
// them.
FeeSelection? armPriorSelection,
NetworkFee? armPriorCustomFee,
// Real fees read from unsigned PSBTs, one slot per tier, so the modal never
// shows rate × vsize arithmetic. Display only: the confirmation rebuilds
// the payin at the committed rate rather than broadcasting a cached PSBT,
// because a price-lock refresh can move the payin amount.
@Default(BitcoinFeePreviewCache.empty)
BitcoinFeePreviewCache feePreviewCache,
// vsize of the last payin build — needed to express an absolute custom fee
// as a rate for the relay-floor checks.
int? bitcoinTxSize,
}) = PayPaymentState;
const factory PayState.success({required FiatPaymentOrder payOrder}) =
PaySuccessState;
Expand Down Expand Up @@ -191,6 +212,8 @@ extension PayWalletSelectionStateX on PayWalletSelectionState {
List<WalletUtxo>? utxos,
int? absoluteFees,
double? exchangeRateEstimate,
FeeOptions? bitcoinFees,
int? bitcoinTxSize,
}) {
return PayPaymentState(
userSummary: userSummary,
Expand All @@ -202,6 +225,8 @@ extension PayWalletSelectionStateX on PayWalletSelectionState {
absoluteFees: absoluteFees,
exchangeRateEstimate: exchangeRateEstimate,
utxos: utxos ?? [],
bitcoinFees: bitcoinFees,
bitcoinTxSize: bitcoinTxSize,
);
}

Expand Down Expand Up @@ -231,6 +256,26 @@ extension PayPaymentStateX on PayPaymentState {
bool get canConfirmPayment => isInternalWallet && selectedUtxos.isNotEmpty;
bool get isProcessing => isConfirmingPayment || isPolling;

/// Fee the payin must be built at, resolved from the committed selection.
/// Null while the presets have not loaded (or custom was selected with no
/// value) — the caller decides, rather than silently reverting to Fastest,
/// which is the bug #2521 describes.
NetworkFee? get selectedFee => switch (selectedFeeOption) {
FeeSelection.fastest => bitcoinFees?.fastest,
FeeSelection.economic => bitcoinFees?.economic,
FeeSelection.slow => bitcoinFees?.slow,
FeeSelection.custom => customFee,
};

/// Fee selection is only editable before the confirmation starts: the
/// transaction on its way to the network was built at the committed rate, so
/// rebuilding under it would pay a different fee than the one shown. Liquid
/// payins have no fee choice at all.
bool get canEditFees =>
!isConfirmingPayment &&
selectedWallet != null &&
!selectedWallet!.isLiquid;

String get bip21InvoiceData {
final order = payOrder;
String invoiceString = '';
Expand Down
81 changes: 68 additions & 13 deletions lib/features/pay/ui/screens/pay_send_payment_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import 'package:bb_mobile/core/utils/amount_conversions.dart';
import 'package:bb_mobile/core/utils/amount_formatting.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/core/widgets/buttons/button.dart';
import 'package:bb_mobile/core/widgets/fees/fee_options_modal.dart';
import 'package:bb_mobile/core/widgets/fees/fee_selection_label.dart';
import 'package:bb_mobile/core/widgets/loading/fading_linear_progress.dart';
import 'package:bb_mobile/core/widgets/loading/loading_line_content.dart';
import 'package:bb_mobile/core/widgets/scrollable_column.dart';
Expand Down Expand Up @@ -207,15 +209,8 @@ class PaySendPaymentScreen extends StatelessWidget {
: context.loc.paySecureBitcoinWallet
: ''),
),
if (wallet != null && !wallet.isLiquid) ...[
_DetailRow(
title: context.loc.payFeePriority,
value: context.loc.payFastest,
onTap: () {
debugPrint('Tapped Fee Priority');
},
),
],
// Liquid payins pay the network minimum, so there is nothing to pick.
if (wallet != null && !wallet.isLiquid) const _FeePriorityRow(),
_DetailRow(
title: context.loc.payNetworkFees,
value: context.select((PayBloc bloc) {
Expand All @@ -232,10 +227,7 @@ class PaySendPaymentScreen extends StatelessWidget {
_BottomButtons(
onContinuePressed: () {
context.read<PayBloc>().add(
const PayEvent.sendPaymentConfirmed(
feeSelection: FeeSelection.fastest,
customFee: null,
),
const PayEvent.sendPaymentConfirmed(),
);
},
),
Expand Down Expand Up @@ -278,6 +270,69 @@ class PaySendPaymentScreen extends StatelessWidget {
}
}

/// "Fee Priority" row: opens the shared fee modal and shows the committed
/// selection (#2521). The row goes inert — plain text, no chevron — once the
/// confirmation starts, since the payin being signed was built at the rate
/// showing here.
class _FeePriorityRow extends StatelessWidget {
const _FeePriorityRow();

@override
Widget build(BuildContext context) {
final (selectedFeeOption, customFee, canEditFees) = context.select((
PayBloc bloc,
) {
final state = bloc.state;
if (state is! PayPaymentState) {
return (FeeSelection.fastest, null as NetworkFee?, false);
}
return (state.selectedFeeOption, state.customFee, state.canEditFees);
});

return _DetailRow(
title: context.loc.payFeePriority,
value: feeSelectionRowLabel(
context,
selection: selectedFeeOption,
customFee: customFee,
fastestLabel: context.loc.payFastest,
),
onTap: canEditFees
? () async {
final bloc = context.read<PayBloc>();
final selected = await BlurredBottomSheet.show<String>(
context: context,
child: FeeOptionsModal(
viewState: bloc,
actions: bloc,
defaultAbsoluteCustomFee: false,
customFeeColors: FeeModalCustomFeeColors(
tile: context.appColors.surface,
shadow: context.appColors.border,
unselectedIcon: context.appColors.textMuted,
),
),
);
if (selected != null) {
// A preset tile was tapped; the handler discards any arm left
// over from typing in the custom field.
bloc.add(
PayEvent.feeOptionSelected(
FeeSelectionName.fromString(selected),
),
);
} else {
// Dismissed without picking. Typing IS the selection and
// dismissing IS the apply, so a typed rate is committed here
// (or rolled back when it is below the relay floor).
bloc.add(const PayEvent.customFeeFinalized());
}
}
: null,
);
}
}

class _DetailRow extends StatelessWidget {
final String title;
final String? value;
Expand Down
Loading