diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ed4e96779..4049d5690 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'; @@ -67,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'; @@ -195,9 +194,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 +272,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 +480,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 +559,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 +609,11 @@ class _ConfirmTransactionViewState @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(walletId)); + 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; final wallet = ref.watch(pWallets).getWallet(walletId); @@ -673,13 +674,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 +758,35 @@ 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) + RecipientAmountList( + coin: coin, + recipients: sentRecipients, + labelStyle: STextStyles.smallMed12(context), + valueStyle: 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 +924,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 +1102,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 +1121,56 @@ 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) + RecipientAmountList( + coin: coin, + recipients: sentRecipients, + labelStyle: + STextStyles.desktopTextExtraExtraSmall( context, - ).extension()!.textDark, - ), - ), + ), + valueStyle: + STextStyles.desktopTextExtraExtraSmall( + context, + ).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 +1192,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1174,9 +1203,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 +1227,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1331,9 +1360,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 +1422,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 +1453,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 +1473,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 +1499,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 +1522,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 +1540,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, ), ), ), 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..05fcdda5a --- /dev/null +++ b/lib/pages/send_view/sub_widgets/multi_recipient_summary.dart @@ -0,0 +1,182 @@ +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/amount/amount_unit.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 content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("${recipients.length} recipients", style: labelStyle), + CustomTextButton(text: "Clear", onTap: onClear), + ], + ), + const SizedBox(height: 12), + RecipientAmountList( + coin: coin, + recipients: recipients, + labelStyle: labelStyle, + valueStyle: valueStyle, + ), + ], + ); + + if (Util.isDesktop) { + return RoundedContainer( + color: Colors.transparent, + borderColor: colors.textFieldDefaultBG, + child: content, + ); + } + 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/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..ed76ac6fc --- /dev/null +++ b/test/pages/send_view/sub_widgets/multi_recipient_summary_test.dart @@ -0,0 +1,210 @@ +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 pump(WidgetTester tester, Widget child) => tester.pumpWidget( + ProviderScope( + overrides: [ + pAmountFormatter.overrideWithProvider( + (_) => Provider((_) => formatter), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + 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"}", + (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("Recipient 2"), findsOneWidget); + expect(find.text("address0"), findsOneWidget); + expect(find.text("bad"), 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); + }, + ); + + 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); + }, + ); + } + }); +}