From a785f72fd181ded721f8e690f1681e3ab30f4b4f Mon Sep 17 00:00:00 2001 From: Waozi Date: Fri, 2 Oct 2026 15:26:10 -0300 Subject: [PATCH 1/2] Support multi-recipient Monero payment URIs Monero payment URIs can list several recipients, e.g. `monero:A;B?tx_amount=1;2&recipient_name=x;y`. Parse these into PaymentUriData.recipients, requiring one amount (and, when given, one name) per address. Callers must opt in with allowMultipleRecipients, so screens that handle a single address never drop recipients silently. On the mobile and desktop send screens, a scanned, pasted, or typed multi-recipient URI replaces the address field with a read-only list of recipients, locks the amount to their total, and builds one transaction with an output per recipient. Clear returns to the normal form. The confirmation screen lists every recipient with its amount. confirm_transaction_view.dart is also run through dart format, as the changed-files format check requires. --- .../send_view/confirm_transaction_view.dart | 276 ++++++++++-------- lib/pages/send_view/send_view.dart | 91 +++++- .../sub_widgets/multi_recipient_summary.dart | 126 ++++++++ .../wallet_view/sub_widgets/desktop_send.dart | 88 +++++- lib/utilities/address_utils.dart | 141 +++++++-- test/address_utils_test.dart | 115 ++++++++ .../multi_recipient_summary_test.dart | 189 ++++++++++++ 7 files changed, 871 insertions(+), 155 deletions(-) create mode 100644 lib/pages/send_view/sub_widgets/multi_recipient_summary.dart create mode 100644 test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ed4e96779..c7cddc737 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -37,9 +37,7 @@ import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/crypto_currency/coins/epiccash.dart'; -import '../../wallets/crypto_currency/coins/ethereum.dart'; -import '../../wallets/crypto_currency/coins/mimblewimblecoin.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; @@ -195,9 +193,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -274,9 +271,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -483,9 +479,8 @@ class _ConfirmTransactionViewState if (context.mounted) { if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -563,9 +558,9 @@ class _ConfirmTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), onPressed: () { @@ -613,6 +608,9 @@ class _ConfirmTransactionViewState @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(walletId)); + final sentRecipients = + widget.txData.recipients?.where((e) => !e.isChange).toList() ?? []; + final hasMultipleRecipients = sentRecipients.length > 1; final String unit; final wallet = ref.watch(pWallets).getWallet(walletId); @@ -673,13 +671,13 @@ class _ConfirmTransactionViewState condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -757,25 +755,34 @@ class _ConfirmTransactionViewState Text( widget.isPaynymTransaction ? "PayNym recipient" + : hasMultipleRecipients + ? "Recipients" : "Recipient", style: STextStyles.smallMed12(context), ), const SizedBox(height: 4), - Text( - widget.isPaynymTransaction - ? widget.txData.paynymAccountLite!.nymName - : widget - .txData - .recipients - ?.firstOrNull - ?.address ?? - widget - .txData - .sparkRecipients! - .first - .address, - style: STextStyles.itemSubtitle12(context), - ), + if (hasMultipleRecipients) + _RecipientList( + coin: coin, + recipients: sentRecipients, + style: STextStyles.itemSubtitle12(context), + ) + else + Text( + widget.isPaynymTransaction + ? widget.txData.paynymAccountLite!.nymName + : widget + .txData + .recipients + ?.firstOrNull + ?.address ?? + widget + .txData + .sparkRecipients! + .first + .address, + style: STextStyles.itemSubtitle12(context), + ), ], ), ), @@ -913,18 +920,18 @@ class _ConfirmTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -1091,9 +1098,9 @@ class _ConfirmTransactionViewState ), Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), Padding( padding: const EdgeInsets.all(12), @@ -1110,38 +1117,52 @@ class _ConfirmTransactionViewState ), ), const SizedBox(height: 2), - SelectableText( - // TODO: [prio=med] spark transaction specifics - better handling - widget.isPaynymTransaction - ? widget.txData.paynymAccountLite!.nymName - : widget - .txData - .recipients - ?.firstOrNull - ?.address ?? - widget - .txData - .sparkRecipients! - .first - .address, - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( + if (hasMultipleRecipients) + _RecipientList( + coin: coin, + recipients: sentRecipients, + style: + STextStyles.desktopTextExtraExtraSmall( context, - ).extension()!.textDark, - ), - ), + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ) + else + SelectableText( + // TODO: [prio=med] spark transaction specifics - better handling + widget.isPaynymTransaction + ? widget.txData.paynymAccountLite!.nymName + : widget + .txData + .recipients + ?.firstOrNull + ?.address ?? + widget + .txData + .sparkRecipients! + .first + .address, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), ], ), ), if (widget.isPaynymTransaction) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (widget.isPaynymTransaction) Padding( @@ -1163,9 +1184,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1174,9 +1195,9 @@ class _ConfirmTransactionViewState if (coin is Ethereum) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (coin is Ethereum) Padding( @@ -1198,9 +1219,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1331,9 +1352,9 @@ class _ConfirmTransactionViewState focusNode: _noteFocusNode, style: STextStyles.desktopTextExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, height: 1.8, ), onChanged: (_) => setState(() {}), @@ -1393,9 +1414,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -1424,9 +1445,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -1444,22 +1465,21 @@ class _ConfirmTransactionViewState padding: isDesktop ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) : const EdgeInsets.all(12), - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.titleBold12(context).copyWith( color: Theme.of(context) .extension()! @@ -1471,13 +1491,12 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.itemSubtitle12(context).copyWith( color: Theme.of(context) .extension()! @@ -1495,16 +1514,16 @@ class _ConfirmTransactionViewState ? const EdgeInsets.symmetric(horizontal: 32, vertical: 8) : const EdgeInsets.symmetric(vertical: 8), child: RoundedContainer( - color: Theme.of( - context, - ).extension()!.warningBackground, + color: Theme.of(context) + .extension()! + .warningBackground, child: Row( children: [ Icon( Icons.warning_amber_rounded, - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, size: 20, ), const SizedBox(width: 8), @@ -1513,9 +1532,9 @@ class _ConfirmTransactionViewState "This transaction spends a UTXO containing " "an ordinal inscription.", style: STextStyles.smallMed12(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), ), ), @@ -1621,3 +1640,32 @@ class _ConfirmTransactionViewState ); } } + +class _RecipientList extends ConsumerWidget { + const _RecipientList({ + required this.coin, + required this.recipients, + required this.style, + }); + + final CryptoCurrency coin; + final List recipients; + final TextStyle style; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formatter = ref.watch(pAmountFormatter(coin)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < recipients.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + SelectableText(recipients[i].address, style: style), + Text(formatter.format(recipients[i].amount), style: style), + ], + ], + ); + } +} diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 13615a092..9d4d96902 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -24,6 +24,7 @@ import '../../models/isar/models/isar_models.dart'; import '../../models/mwc_slatepack_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../models/send_view_auto_fill_data.dart'; +import '../../notifications/show_flush_bar.dart'; import '../../providers/providers.dart'; import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; @@ -87,6 +88,7 @@ import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; +import 'sub_widgets/multi_recipient_summary.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; @@ -148,6 +150,9 @@ class _SendViewState extends ConsumerState { Amount? _cachedAmountToSend; String? _address; + /// Set while sending to the recipients of a multi-recipient payment URI. + List? _multiRecipients; + bool _addressToggleFlag = false; bool _isFiroExWarningDisplayed = false; @@ -157,8 +162,49 @@ class _SendViewState extends ConsumerState { Set selectedUTXOs = {}; + void _applyMultiRecipientUri(PaymentUriData paymentData) { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } + _setOpReturnData(null); + _address = null; + sendToController.text = ""; + final total = recipients.total; + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(total); + ref.read(pSendAmount.notifier).state = total; + + setState(() { + _multiRecipients = recipients; + _addressToggleFlag = false; + }); + _setValidAddressProviders(_address); + } + + void _clearMultiRecipients() { + clearSendForm(); + _setValidAddressProviders(_address); + } + void _applyUri(PaymentUriData paymentData) { try { + if (paymentData.isMultiRecipient) { + _applyMultiRecipientUri(paymentData); + return; + } + _multiRecipients = null; + // auto fill address _address = paymentData.address.trim(); @@ -260,6 +306,7 @@ class _SendViewState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( content, logging: Logging.instance, + allowMultipleRecipients: true, ); if (paymentData != null && @@ -328,6 +375,7 @@ class _SendViewState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, + allowMultipleRecipients: true, ); if (paymentData != null && @@ -496,7 +544,10 @@ class _SendViewState extends ConsumerState { late Amount _currentFee; void _setValidAddressProviders(String? address) { - if (isPaynymSend) { + if (_multiRecipients != null) { + ref.read(pValidSendToAddress.notifier).state = _multiRecipients! + .allValidFor(coin); + } else if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; } else { final wallet = ref.read(pWallets).getWallet(walletId); @@ -1077,14 +1128,18 @@ class _SendViewState extends ConsumerState { final memo = coin is Stellar ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( - recipients: [ - TxRecipient( - address: _address!, - amount: amount, - isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, - ), - ], + recipients: + _multiRecipients?.toTxRecipients(wallet.cryptoCurrency) ?? + [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: wallet.cryptoCurrency.getAddressType( + _address!, + )!, + ), + ], memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, @@ -1193,6 +1248,7 @@ class _SendViewState extends ConsumerState { memoController.text = ""; _address = ""; _addressToggleFlag = false; + _multiRecipients = null; _setOpReturnData(null); setState(() {}); } @@ -1722,7 +1778,15 @@ class _SendViewState extends ConsumerState { readOnly: true, style: STextStyles.fieldLabel(context), ), - if (!isPaynymSend && !isSlatepackMode) + if (_multiRecipients != null) + MultiRecipientSummary( + coin: coin, + recipients: _multiRecipients!, + onClear: _clearMultiRecipients, + ), + if (!isPaynymSend && + !isSlatepackMode && + _multiRecipients == null) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1755,6 +1819,7 @@ class _SendViewState extends ConsumerState { AddressUtils.parsePaymentUri( trimmed, logging: Logging.instance, + allowMultipleRecipients: true, ); if (parsed != null) { _applyUri(parsed); @@ -2190,7 +2255,9 @@ class _SendViewState extends ConsumerState { style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - if (coin is! Ethereum && coin is! Tezos) + if (coin is! Ethereum && + coin is! Tezos && + _multiRecipients == null) CustomTextButton( text: _getSendAllTitle( showCoinControl, @@ -2203,6 +2270,7 @@ class _SendViewState extends ConsumerState { ), const SizedBox(height: 8), TextField( + readOnly: _multiRecipients != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -2271,6 +2339,7 @@ class _SendViewState extends ConsumerState { const SizedBox(height: 8), if (Prefs.instance.externalCalls) TextField( + readOnly: _multiRecipients != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false diff --git a/lib/pages/send_view/sub_widgets/multi_recipient_summary.dart b/lib/pages/send_view/sub_widgets/multi_recipient_summary.dart new file mode 100644 index 000000000..ece664fcf --- /dev/null +++ b/lib/pages/send_view/sub_widgets/multi_recipient_summary.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/address_utils.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/amount/amount_formatter.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/models/tx_data.dart'; +import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../../widgets/rounded_white_container.dart'; + +/// A recipient of a multi-recipient payment URI, with its amount parsed for +/// the sending coin. +typedef SendRecipient = ({String address, Amount amount, String? label}); + +/// Converts the recipients of [paymentData] to amounts of [coin]. +/// +/// Returns null if any recipient has a missing, invalid, or zero amount. +List? parseUriRecipients( + PaymentUriData paymentData, + CryptoCurrency coin, +) { + final List recipients = []; + for (final recipient in paymentData.recipients) { + final amount = recipient.amount == null + ? null + : Amount.tryParseCanonicalAmount( + recipient.amount!, + fractionDigits: coin.fractionDigits, + truncateOverprecision: true, + ); + if (amount == null || amount <= Amount.zero) { + return null; + } + recipients.add(( + address: recipient.address, + amount: amount, + label: recipient.label, + )); + } + return recipients; +} + +extension SendRecipientsExt on List { + Amount get total => map((e) => e.amount).reduce((a, b) => a + b); + + bool allValidFor(CryptoCurrency coin) => + every((e) => coin.validateAddress(e.address)); + + List toTxRecipients(CryptoCurrency coin) => [ + for (final e in this) + TxRecipient( + address: e.address, + amount: e.amount, + isChange: false, + addressType: coin.getAddressType(e.address)!, + ), + ]; +} + +/// Read-only list of the recipients of a multi-recipient payment URI. +class MultiRecipientSummary extends ConsumerWidget { + const MultiRecipientSummary({ + super.key, + required this.coin, + required this.recipients, + required this.onClear, + }); + + final CryptoCurrency coin; + final List recipients; + final VoidCallback onClear; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).extension()!; + final labelStyle = Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.smallMed12(context); + final valueStyle = Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith(color: colors.textDark) + : STextStyles.itemSubtitle12(context); + final formatter = ref.watch(pAmountFormatter(coin)); + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("${recipients.length} recipients", style: labelStyle), + CustomTextButton(text: "Clear", onTap: onClear), + ], + ), + for (final recipient in recipients) ...[ + const SizedBox(height: 12), + if (recipient.label != null) + Text(recipient.label!, style: labelStyle), + SelectableText(recipient.address, style: valueStyle), + if (!coin.validateAddress(recipient.address)) + Text( + "Invalid address", + style: STextStyles.label(context) + .copyWith(color: colors.textError), + ), + const SizedBox(height: 4), + Text(formatter.format(recipient.amount), style: valueStyle), + ], + ], + ); + + if (Util.isDesktop) { + return RoundedContainer( + color: Colors.transparent, + borderColor: colors.textFieldDefaultBG, + child: content, + ); + } + return RoundedWhiteContainer(child: content); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 09d2e6797..06ea84ab4 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -24,9 +24,11 @@ import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/mwc_slatepack_models.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../pages/send_view/sub_widgets/epic_slatepack_dialog.dart'; +import '../../../../pages/send_view/sub_widgets/multi_recipient_summary.dart'; import '../../../../pages/send_view/sub_widgets/mwc_slatepack_dialog.dart'; import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../../providers/providers.dart'; @@ -134,6 +136,9 @@ class _DesktopSendState extends ConsumerState { Amount? _cachedAmountToSend; String? _address; + /// Set while sending to the recipients of a multi-recipient payment URI. + List? _multiRecipients; + bool _addressToggleFlag = false; bool _isFiroExWarningDisplayed = false; @@ -721,14 +726,18 @@ class _DesktopSendState extends ConsumerState { final memo = hasOptionalMemo ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( - recipients: [ - TxRecipient( - address: _address!, - amount: amount, - isChange: false, - addressType: wallet.cryptoCurrency.getAddressType(_address!)!, - ), - ], + recipients: + _multiRecipients?.toTxRecipients(wallet.cryptoCurrency) ?? + [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: wallet.cryptoCurrency.getAddressType( + _address!, + )!, + ), + ], memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, @@ -862,6 +871,7 @@ class _DesktopSendState extends ConsumerState { nonceController.text = ""; _address = ""; _addressToggleFlag = false; + _multiRecipients = null; _syncFeeAmount(null); _setOpReturnData(null); setState(() {}); @@ -939,6 +949,7 @@ class _DesktopSendState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( qrCodeData, logging: Logging.instance, + allowMultipleRecipients: true, ); if (paymentData != null && @@ -965,7 +976,10 @@ class _DesktopSendState extends ConsumerState { } void _setValidAddressProviders(String? address) { - if (isPaynymSend) { + if (_multiRecipients != null) { + ref.read(pValidSendToAddress.notifier).state = _multiRecipients! + .allValidFor(coin); + } else if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; } else { final wallet = ref.read(pWallets).getWallet(walletId); @@ -997,8 +1011,49 @@ class _DesktopSendState extends ConsumerState { } } + void _applyMultiRecipientUri(PaymentUriData paymentData) { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + _note = paymentData.message; + _setOpReturnData(null); + _address = null; + sendToController.text = ""; + final total = recipients.total; + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(total); + ref.read(pSendAmount.notifier).state = total; + _syncFeeAmount(total); + + setState(() { + _multiRecipients = recipients; + _addressToggleFlag = false; + }); + _setValidAddressProviders(_address); + } + + void _clearMultiRecipients() { + clearSendForm(); + _note = null; + _setValidAddressProviders(_address); + } + void _applyUri(PaymentUriData paymentData) { try { + if (paymentData.isMultiRecipient) { + _applyMultiRecipientUri(paymentData); + return; + } + _multiRecipients = null; + // auto fill address _address = paymentData.address; sendToController.text = _address!; @@ -1082,6 +1137,7 @@ class _DesktopSendState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( content, logging: Logging.instance, + allowMultipleRecipients: true, ); if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { @@ -1620,7 +1676,7 @@ class _DesktopSendState extends ConsumerState { ), textAlign: TextAlign.left, ), - if (coin is! Ethereum && coin is! Tezos) + if (coin is! Ethereum && coin is! Tezos && _multiRecipients == null) CustomTextButton( text: _getSendAllTitle( showCoinControl, @@ -1632,6 +1688,7 @@ class _DesktopSendState extends ConsumerState { ), const SizedBox(height: 10), TextField( + readOnly: _multiRecipients != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -1693,6 +1750,7 @@ class _DesktopSendState extends ConsumerState { if (Prefs.instance.externalCalls) const SizedBox(height: 10), if (Prefs.instance.externalCalls) TextField( + readOnly: _multiRecipients != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -1794,9 +1852,16 @@ class _DesktopSendState extends ConsumerState { !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 10), + if (_multiRecipients != null) + MultiRecipientSummary( + coin: coin, + recipients: _multiRecipients!, + onClear: _clearMultiRecipients, + ), if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && - ref.watch(pIsSlatepack(widget.walletId)))) + ref.watch(pIsSlatepack(widget.walletId))) && + _multiRecipients == null) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1827,6 +1892,7 @@ class _DesktopSendState extends ConsumerState { final parsed = AddressUtils.parsePaymentUri( trimmed, logging: Logging.instance, + allowMultipleRecipients: true, ); if (parsed != null) { _setOpReturnData(parsed.additionalParams['op_return']); diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index cb57e48e8..ce92bdeb3 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -72,7 +72,13 @@ class AddressUtils { } // Parse query parameters. - result.addAll(_parseQueryParameters(u.queryParameters)); + result.addAll( + _parseQueryParameters( + u.queryParameters, + // Monero URIs may list values for multiple recipients. + allowLists: result["scheme"] == "monero", + ), + ); // Handle Monero-specific fragment (tx_description). if (u.fragment.isNotEmpty && result["scheme"] == "monero") { @@ -92,7 +98,10 @@ class AddressUtils { } /// Helper method to parse and normalize query parameters. - static Map _parseQueryParameters(Map params) { + static Map _parseQueryParameters( + Map params, { + required bool allowLists, + }) { final Map result = {}; params.forEach((key, value) { final lowerKey = key.toLowerCase(); @@ -100,11 +109,12 @@ class AddressUtils { switch (lowerKey) { case 'amount': case 'tx_amount': - final normalized = _normalizeAmount(value); - if (normalized == null) { + final amounts = allowLists ? value.split(';') : [value]; + final normalized = amounts.map(_normalizeAmount).toList(); + if (normalized.contains(null)) { throw FormatException("Invalid payment URI amount: $value"); } - result['amount'] = normalized; + result['amount'] = normalized.join(';'); break; case 'label': case 'recipient_name': @@ -133,10 +143,62 @@ class AddressUtils { return RegExp(r'^(\d+(\.\d+)?|\.\d+)$').hasMatch(trimmed) ? trimmed : null; } + /// Splits a Monero URI's `;` separated addresses, amounts, and recipient + /// names into individual recipients. + static List _parseMoneroRecipients({ + required String address, + required String? amount, + required String? label, + }) { + final addresses = address.split(';').map((e) => e.trim()).toList(); + if (addresses.length == 1) { + if (amount?.contains(';') == true) { + throw FormatException("Invalid payment URI amount: $amount"); + } + return [ + PaymentUriRecipient(address: address, amount: amount, label: label), + ]; + } + + if (addresses.contains("")) { + throw FormatException("Empty address in payment URI: $address"); + } + + final amounts = amount?.split(';'); + if (amounts == null || amounts.length != addresses.length) { + throw const FormatException( + "Payment URI must have one amount per recipient", + ); + } + + final labels = label?.split(';'); + if (labels != null && labels.length != addresses.length) { + throw const FormatException( + "Payment URI must have one name per recipient", + ); + } + + return [ + for (int i = 0; i < addresses.length; i++) + PaymentUriRecipient( + address: addresses[i], + amount: amounts[i], + label: labels?[i].isNotEmpty == true ? labels![i] : null, + ), + ]; + } + /// Centralized method to handle various cryptocurrency URIs and return a common object. /// + /// Multi-recipient URIs are only returned when [allowMultipleRecipients] is + /// true, so callers that handle a single address never drop recipients. + /// /// Returns null on failure to parse - static PaymentUriData? parsePaymentUri(String uri, {Logging? logging}) { + static PaymentUriData? parsePaymentUri( + String uri, { + Logging? logging, + bool allowMultipleRecipients = false, + }) { // hacky check its not just a bcash, ecash, or xel address const cashAddrSchemes = {"bitcoincash", "bchtest", "ecash", "ectest"}; final parsedUri = Uri.tryParse(uri); @@ -158,13 +220,36 @@ class AddressUtils { // Filter out unrecognized parameters. final filteredParams = _filterParams(parsedData); + final amount = filteredParams['amount'] ?? filteredParams['tx_amount']; + final label = filteredParams['label'] ?? filteredParams['recipient_name']; + + final List recipients; + if (scheme == "monero") { + recipients = _parseMoneroRecipients( + address: address, + amount: amount, + label: label, + ); + if (recipients.length > 1 && !allowMultipleRecipients) { + throw const FormatException( + "Multiple recipients are not supported here", + ); + } + } else { + recipients = [ + PaymentUriRecipient( + address: cashAddrSchemes.contains(scheme) + ? "$scheme:$address".toLowerCase() + : address, + amount: amount, + label: label, + ), + ]; + } + return PaymentUriData( scheme: scheme, - address: cashAddrSchemes.contains(scheme) - ? "$scheme:$address".toLowerCase() - : address, - amount: filteredParams['amount'] ?? filteredParams['tx_amount'], - label: filteredParams['label'] ?? filteredParams['recipient_name'], + recipients: recipients, message: filteredParams['message'] ?? filteredParams['tx_description'], paymentId: filteredParams['tx_payment_id'], // Specific to Monero @@ -378,15 +463,37 @@ class AddressUtils { } } -class PaymentUriData { +class PaymentUriRecipient { final String address; - final String? scheme; final String? amount; final String? label; + + const PaymentUriRecipient({required this.address, this.amount, this.label}); + + @override + String toString() => + "PaymentUriRecipient { " + "address: $address, " + "amount: $amount, " + "label: $label" + " }"; +} + +class PaymentUriData { + final String? scheme; + + /// Always contains at least one recipient. + final List recipients; final String? message; final String? paymentId; // Specific to Monero. final Map additionalParams; + String get address => recipients.first.address; + String? get amount => recipients.first.amount; + String? get label => recipients.first.label; + + bool get isMultiRecipient => recipients.length > 1; + CryptoCurrency? get coin => AddressUtils._getCryptoCurrencyByScheme( scheme ?? "", // empty will just return null ); @@ -406,10 +513,8 @@ class PaymentUriData { } PaymentUriData({ - required this.address, + required this.recipients, this.scheme, - this.amount, - this.label, this.message, this.paymentId, required this.additionalParams, @@ -419,10 +524,8 @@ class PaymentUriData { String toString() => "PaymentUriData { " "coin: $coin, " - "address: $address, " - "amount: $amount, " + "recipients: $recipients, " "scheme: $scheme, " - "label: $label, " "message: $message, " "paymentId: $paymentId, " "additionalParams: $additionalParams" diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index dc1bd24e7..6475e2763 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -298,4 +298,119 @@ void main() { expect(parsed?.message, "Some kind of message!"); } }); + + group("Monero multi-recipient payment URIs", () { + const addressA = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx"; + const addressB = "8BnERTpvL5MbCLtj5n9No7J5oE5hHiB3tVCK5cjSvCsx"; + + test("parses addresses, amounts, and names per recipient", () { + final result = AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB?tx_amount=1.5;0.25" + "&recipient_name=Alice;Bob&tx_description=Dinner", + allowMultipleRecipients: true, + ); + + expect(result, isNotNull); + expect(result!.isMultiRecipient, isTrue); + expect(result.recipients.map((e) => e.address), [addressA, addressB]); + expect(result.recipients.map((e) => e.amount), ["1.5", "0.25"]); + expect(result.recipients.map((e) => e.label), ["Alice", "Bob"]); + expect(result.message, "Dinner"); + expect(result.address, addressA); + expect(result.amount, "1.5"); + }); + + test("names are optional", () { + final result = AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB?tx_amount=1;2", + allowMultipleRecipients: true, + ); + + expect(result!.recipients.map((e) => e.label), [null, null]); + }); + + test("parses percent-encoded lists", () { + final result = AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB?recipient_name=Page%3BTips" + "&tx_amount=0.09%3B0.01&tx_description=Great%20stream", + allowMultipleRecipients: true, + ); + + expect(result!.recipients.map((e) => e.address), [addressA, addressB]); + expect(result.recipients.map((e) => e.amount), ["0.09", "0.01"]); + expect(result.recipients.map((e) => e.label), ["Page", "Tips"]); + expect(result.message, "Great stream"); + }); + + test("parses more than two recipients", () { + const addressC = "4C7oeS1rq4w7TxjdBdz2Z6rxDBsJtUpDjN9X8fKCnQsx"; + final result = AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB;$addressC?tx_amount=1;.5;0.000000000001" + "&recipient_name=Alice;;Carol", + allowMultipleRecipients: true, + ); + + expect(result!.recipients.map((e) => e.address), [ + addressA, + addressB, + addressC, + ]); + expect(result.recipients.map((e) => e.amount), [ + "1", + ".5", + "0.000000000001", + ]); + expect(result.recipients.map((e) => e.label), ["Alice", null, "Carol"]); + }); + + test("is rejected unless multiple recipients are allowed", () { + expect( + AddressUtils.parsePaymentUri( + "monero:$addressA;$addressB?tx_amount=1;2", + ), + isNull, + ); + }); + + test("rejects malformed recipient lists", () { + for (final uri in [ + "monero:$addressA;$addressB", + "monero:$addressA;$addressB?tx_amount=1", + "monero:$addressA;$addressB?tx_amount=1;2;3", + "monero:$addressA;$addressB?tx_amount=1;abc", + "monero:$addressA;$addressB?tx_amount=1;", + "monero:$addressA;$addressB?tx_amount=1;2&recipient_name=Alice", + "monero:$addressA;?tx_amount=1;2", + "monero:$addressA?tx_amount=1;2", + ]) { + expect( + AddressUtils.parsePaymentUri(uri, allowMultipleRecipients: true), + isNull, + reason: uri, + ); + } + }); + + test("single recipient URIs are unchanged", () { + final result = AddressUtils.parsePaymentUri( + "monero:$addressA?tx_amount=1.5&recipient_name=Alice;Co", + allowMultipleRecipients: true, + ); + + expect(result!.isMultiRecipient, isFalse); + expect(result.address, addressA); + expect(result.amount, "1.5"); + expect(result.label, "Alice;Co"); + }); + + test("other schemes do not split recipients", () { + expect( + AddressUtils.parsePaymentUri( + "wownero:$addressA;$addressB?tx_amount=1;2", + allowMultipleRecipients: true, + ), + isNull, + ); + }); + }); } diff --git a/test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart b/test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart new file mode 100644 index 000000000..a57d0b1eb --- /dev/null +++ b/test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/multi_recipient_summary.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/address_utils.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +import '../../../sample_data/theme_json.dart'; + +/// Monero that validates addresses without the native library. +class _TestMonero extends Monero { + _TestMonero() : super(CryptoCurrencyNetwork.main); + + @override + bool validateAddress(String address) => address.startsWith("address"); +} + +void main() { + final monero = Monero(CryptoCurrencyNetwork.main); + + PaymentUriData uri(List amounts, {List? addresses}) => + PaymentUriData( + scheme: "monero", + recipients: [ + for (int i = 0; i < amounts.length; i++) + PaymentUriRecipient( + address: addresses?[i] ?? "address$i", + amount: amounts[i], + label: i == 0 ? "Alice" : null, + ), + ], + additionalParams: const {}, + ); + + test("parses each recipient's amount in the coin's units", () { + final recipients = parseUriRecipients( + uri(["1.5", "0.000000000001"]), + monero, + ); + + expect(recipients, isNotNull); + expect(recipients!.map((e) => e.address), ["address0", "address1"]); + expect(recipients.map((e) => e.label), ["Alice", null]); + expect(recipients.map((e) => e.amount.raw), [ + BigInt.from(1500000000000), + BigInt.one, + ]); + expect(recipients.total.raw, BigInt.from(1500000000001)); + }); + + test("truncates amounts more precise than the coin", () { + final recipients = parseUriRecipients( + uri(["0.1234567890129", "1"]), + monero, + ); + + expect(recipients!.first.amount.raw, BigInt.from(123456789012)); + }); + + test("rejects missing and zero amounts", () { + expect(parseUriRecipients(uri(["1", null]), monero), isNull); + expect(parseUriRecipients(uri(["1", "0"]), monero), isNull); + expect(parseUriRecipients(uri(["1", "0.0000000000001"]), monero), isNull); + }); + + test("builds one transaction output per recipient", () { + final coin = _TestMonero(); + final recipients = parseUriRecipients(uri(["1", "2"]), coin)!; + + expect(recipients.allValidFor(coin), isTrue); + + final outputs = recipients.toTxRecipients(coin); + expect(outputs.map((e) => e.address), ["address0", "address1"]); + expect(outputs.map((e) => e.amount.raw), [ + BigInt.from(1000000000000), + BigInt.from(2000000000000), + ]); + expect(outputs.map((e) => e.isChange), [false, false]); + expect(outputs.map((e) => e.addressType), [ + AddressType.cryptonote, + AddressType.cryptonote, + ]); + }); + + test("is invalid if any recipient address is invalid", () { + final coin = _TestMonero(); + final recipients = parseUriRecipients( + uri(["1", "2"], addresses: ["address0", "bad"]), + coin, + )!; + + expect(recipients.allValidFor(coin), isFalse); + }); + + group("MultiRecipientSummary", () { + final coin = _TestMonero(); + final formatter = AmountFormatter( + unit: AmountUnit.normal, + locale: "en_US", + coin: coin, + maxDecimals: 12, + ); + + tearDown(() => Util.screenWidth = null); + + Future pumpSummary( + WidgetTester tester, + List recipients, { + VoidCallback? onClear, + }) => tester.pumpWidget( + ProviderScope( + overrides: [ + pAmountFormatter.overrideWithProvider( + (_) => Provider((_) => formatter), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: SingleChildScrollView( + child: MultiRecipientSummary( + coin: coin, + recipients: recipients, + onClear: onClear ?? () {}, + ), + ), + ), + ), + ), + ); + + for (final isDesktop in [true, false]) { + testWidgets( + "lists every recipient on ${isDesktop ? "desktop" : "mobile"}", + (tester) async { + Util.screenWidth = isDesktop ? null : 400; + final recipients = parseUriRecipients( + uri(["1.5", "0.25"], addresses: ["address0", "bad"]), + coin, + )!; + + await pumpSummary(tester, recipients); + + expect(find.text("2 recipients"), findsOneWidget); + expect(find.text("Alice"), findsOneWidget); + expect(find.text("address0"), findsOneWidget); + expect(find.text("bad"), findsOneWidget); + for (final recipient in recipients) { + expect( + find.text(formatter.format(recipient.amount)), + findsOneWidget, + ); + } + expect(find.text("Invalid address"), findsOneWidget); + }, + ); + + testWidgets( + "Clear calls onClear on ${isDesktop ? "desktop" : "mobile"}", + (tester) async { + Util.screenWidth = isDesktop ? null : 400; + int cleared = 0; + await pumpSummary( + tester, + parseUriRecipients(uri(["1", "2"]), coin)!, + onClear: () => cleared++, + ); + + await tester.tap(find.text("Clear", findRichText: true)); + await tester.pumpAndSettle(); + + expect(cleared, 1); + }, + ); + } + }); +} From 9b3a522bc438db11ae3db7e8df9c726fd5316e87 Mon Sep 17 00:00:00 2001 From: Waozi Date: Sun, 4 Oct 2026 13:28:54 -0300 Subject: [PATCH 2/2] Show each recipient's amount separately Each recipient's amount sat directly under its address in the same style, so it read as part of the address, and was padded to twelve decimals (1.000000000000 XMR). List each recipient as its own section with a labelled amount, as the request gives it (1 XMR), separated by dividers. The amount field below remains the total. The confirmation screen uses the same list. --- .../send_view/confirm_transaction_view.dart | 49 +++-------- .../sub_widgets/multi_recipient_summary.dart | 86 +++++++++++++++---- .../multi_recipient_summary_test.dart | 61 ++++++++----- 3 files changed, 126 insertions(+), 70 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index c7cddc737..4049d5690 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -65,6 +65,7 @@ import '../../wl_gen/interfaces/libepiccash_interface.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; +import 'sub_widgets/multi_recipient_summary.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; @@ -608,8 +609,10 @@ class _ConfirmTransactionViewState @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(walletId)); - final sentRecipients = - widget.txData.recipients?.where((e) => !e.isChange).toList() ?? []; + final List sentRecipients = [ + for (final e in widget.txData.recipients ?? []) + if (!e.isChange) (address: e.address, amount: e.amount, label: null), + ]; final hasMultipleRecipients = sentRecipients.length > 1; final String unit; @@ -762,10 +765,11 @@ class _ConfirmTransactionViewState ), const SizedBox(height: 4), if (hasMultipleRecipients) - _RecipientList( + RecipientAmountList( coin: coin, recipients: sentRecipients, - style: STextStyles.itemSubtitle12(context), + labelStyle: STextStyles.smallMed12(context), + valueStyle: STextStyles.itemSubtitle12(context), ) else Text( @@ -1118,10 +1122,14 @@ class _ConfirmTransactionViewState ), const SizedBox(height: 2), if (hasMultipleRecipients) - _RecipientList( + RecipientAmountList( coin: coin, recipients: sentRecipients, - style: + labelStyle: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + valueStyle: STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( @@ -1640,32 +1648,3 @@ class _ConfirmTransactionViewState ); } } - -class _RecipientList extends ConsumerWidget { - const _RecipientList({ - required this.coin, - required this.recipients, - required this.style, - }); - - final CryptoCurrency coin; - final List recipients; - final TextStyle style; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final formatter = ref.watch(pAmountFormatter(coin)); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < recipients.length; i++) ...[ - if (i > 0) const SizedBox(height: 8), - SelectableText(recipients[i].address, style: style), - Text(formatter.format(recipients[i].amount), style: style), - ], - ], - ); - } -} diff --git a/lib/pages/send_view/sub_widgets/multi_recipient_summary.dart b/lib/pages/send_view/sub_widgets/multi_recipient_summary.dart index ece664fcf..05fcdda5a 100644 --- a/lib/pages/send_view/sub_widgets/multi_recipient_summary.dart +++ b/lib/pages/send_view/sub_widgets/multi_recipient_summary.dart @@ -5,6 +5,7 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/address_utils.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; +import '../../../utilities/amount/amount_unit.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; @@ -85,7 +86,6 @@ class MultiRecipientSummary extends ConsumerWidget { ? STextStyles.desktopTextExtraExtraSmall(context) .copyWith(color: colors.textDark) : STextStyles.itemSubtitle12(context); - final formatter = ref.watch(pAmountFormatter(coin)); final content = Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -97,20 +97,13 @@ class MultiRecipientSummary extends ConsumerWidget { CustomTextButton(text: "Clear", onTap: onClear), ], ), - for (final recipient in recipients) ...[ - const SizedBox(height: 12), - if (recipient.label != null) - Text(recipient.label!, style: labelStyle), - SelectableText(recipient.address, style: valueStyle), - if (!coin.validateAddress(recipient.address)) - Text( - "Invalid address", - style: STextStyles.label(context) - .copyWith(color: colors.textError), - ), - const SizedBox(height: 4), - Text(formatter.format(recipient.amount), style: valueStyle), - ], + const SizedBox(height: 12), + RecipientAmountList( + coin: coin, + recipients: recipients, + labelStyle: labelStyle, + valueStyle: valueStyle, + ), ], ); @@ -124,3 +117,66 @@ class MultiRecipientSummary extends ConsumerWidget { return RoundedWhiteContainer(child: content); } } + +/// Each recipient's address and amount, separated by dividers. +class RecipientAmountList extends ConsumerWidget { + const RecipientAmountList({ + super.key, + required this.coin, + required this.recipients, + required this.labelStyle, + required this.valueStyle, + }); + + final CryptoCurrency coin; + final List recipients; + final TextStyle labelStyle; + final TextStyle valueStyle; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).extension()!; + final formatter = ref.watch(pAmountFormatter(coin)); + + final divider = Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Container(height: 1, color: colors.backgroundAppBar), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < recipients.length; i++) ...[ + if (i > 0) divider, + Text(recipients[i].label ?? "Recipient ${i + 1}", style: labelStyle), + const SizedBox(height: 4), + SelectableText(recipients[i].address, style: valueStyle), + if (!coin.validateAddress(recipients[i].address)) + Text( + "Invalid address", + style: STextStyles.label(context) + .copyWith(color: colors.textError), + ), + const SizedBox(height: 8), + Row( + children: [ + Text("Amount", style: labelStyle), + const SizedBox(width: 12), + Expanded( + child: SelectableText( + // Without trailing zeros, e.g. "1 XMR" rather than + // "1.000000000000 XMR". + "${formatter.formatEditable(recipients[i].amount)} " + "${formatter.unit.unitForCoin(coin)}", + style: valueStyle, + textAlign: TextAlign.right, + ), + ), + ], + ), + ], + ], + ); + } +} diff --git a/test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart b/test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart index a57d0b1eb..ed76ac6fc 100644 --- a/test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart +++ b/test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart @@ -109,11 +109,7 @@ void main() { tearDown(() => Util.screenWidth = null); - Future pumpSummary( - WidgetTester tester, - List recipients, { - VoidCallback? onClear, - }) => tester.pumpWidget( + Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( ProviderScope( overrides: [ pAmountFormatter.overrideWithProvider( @@ -128,19 +124,45 @@ void main() { ), ], ), - home: Scaffold( - body: SingleChildScrollView( - child: MultiRecipientSummary( - coin: coin, - recipients: recipients, - onClear: onClear ?? () {}, - ), - ), - ), + home: Scaffold(body: SingleChildScrollView(child: child)), ), ), ); + Future pumpSummary( + WidgetTester tester, + List recipients, { + VoidCallback? onClear, + }) => pump( + tester, + MultiRecipientSummary( + coin: coin, + recipients: recipients, + onClear: onClear ?? () {}, + ), + ); + + testWidgets("shows amounts without trailing zeros", (tester) async { + await pump( + tester, + RecipientAmountList( + coin: coin, + recipients: parseUriRecipients( + uri(["1", "2.50", "0.000000000001"]), + coin, + )!, + labelStyle: const TextStyle(), + valueStyle: const TextStyle(), + ), + ); + + expect(find.text("Recipient 3"), findsOneWidget); + expect(find.text("Amount"), findsNWidgets(3)); + expect(find.text("1 XMR"), findsOneWidget); + expect(find.text("2.5 XMR"), findsOneWidget); + expect(find.text("0.000000000001 XMR"), findsOneWidget); + }); + for (final isDesktop in [true, false]) { testWidgets( "lists every recipient on ${isDesktop ? "desktop" : "mobile"}", @@ -155,14 +177,13 @@ void main() { expect(find.text("2 recipients"), findsOneWidget); expect(find.text("Alice"), findsOneWidget); + expect(find.text("Recipient 2"), findsOneWidget); expect(find.text("address0"), findsOneWidget); expect(find.text("bad"), findsOneWidget); - for (final recipient in recipients) { - expect( - find.text(formatter.format(recipient.amount)), - findsOneWidget, - ); - } + expect(find.text("Amount"), findsNWidgets(2)); + expect(find.text("1.5 XMR"), findsOneWidget); + expect(find.text("0.25 XMR"), findsOneWidget); + expect(find.text("Total"), findsNothing); expect(find.text("Invalid address"), findsOneWidget); }, );