diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 0bd308132..e26f8c96c 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.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; import 'sub_widgets/split_transaction_warning.dart'; @@ -83,6 +82,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.recipientLabels, }); static const String routeName = "/confirmTransactionView"; @@ -97,6 +97,10 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + /// The payment request's names for the recipients of a transaction with + /// several recipients, in order. + final List? recipientLabels; + @override ConsumerState createState() => _ConfirmTransactionViewState(); @@ -623,6 +627,11 @@ class _ConfirmTransactionViewState @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(walletId)); + final sentRecipients = [ + for (final e in widget.txData.recipients ?? []) + if (!e.isChange) e, + ]; + final hasMultipleRecipients = sentRecipients.length > 1; final String unit; final wallet = ref.watch(pWallets).getWallet(walletId); @@ -767,25 +776,36 @@ 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, + labels: widget.recipientLabels, + 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), + ), ], ), ), @@ -1120,29 +1140,48 @@ 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(context) - .extension()! - .textDark, - ), - ), + if (hasMultipleRecipients) + RecipientAmountList( + coin: coin, + recipients: sentRecipients, + labels: widget.recipientLabels, + labelStyle: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + 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, + ), + ), ], ), ), diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index ce67095ce..2c7fd7b3e 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -83,7 +83,7 @@ class _FrostSendViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(walletId) as BitcoinFrostWallet; final recipients = recipientWidgetIndexes - .map((i) => ref.read(pRecipient(i).state).state) + .map((i) => ref.read(pRecipient((walletId: walletId, index: i)))) .map( (e) => TxRecipient( address: e!.address, @@ -190,7 +190,7 @@ class _FrostSendViewState extends ConsumerState { bool _validateRecipientFormStatesHelper() { for (final i in recipientWidgetIndexes) { - final state = ref.read(pRecipient(i)); + final state = ref.read(pRecipient((walletId: walletId, index: i))); if (state?.amount == null || state?.address == null || state!.address.isEmpty) { @@ -376,6 +376,7 @@ class _FrostSendViewState extends ConsumerState { ), child: Recipient( key: Key("recipientKey_${recipientWidgetIndexes[i]}"), + walletId: walletId, index: recipientWidgetIndexes[i], displayNumber: i + 1, coin: coin, @@ -387,8 +388,10 @@ class _FrostSendViewState extends ConsumerState { : () { ref .read( - pRecipient(recipientWidgetIndexes[i]) - .notifier, + pRecipient(( + walletId: walletId, + index: recipientWidgetIndexes[i], + )).notifier, ) .state = null; diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart index d7bc33015..9e65d81cc 100644 --- a/lib/pages/send_view/frost_ms/recipient.dart +++ b/lib/pages/send_view/frost_ms/recipient.dart @@ -17,6 +17,7 @@ import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../widgets/icon_widgets/x_icon.dart'; @@ -31,14 +32,21 @@ import '../../../widgets/textfield_icon_button.dart'; // ); // }); -final pRecipient = - StateProvider.family<({String address, Amount? amount})?, int>( - (ref, index) => null, - ); +/// A recipient entered on a send screen. [label] is a payment request's +/// name for [address]. +typedef RecipientData = ({String address, Amount? amount, String? label}); + +/// A recipient form: the sending wallet and the form's index on its screen. +typedef RecipientId = ({String walletId, int index}); + +final pRecipient = StateProvider.family( + (ref, id) => null, +); class Recipient extends ConsumerStatefulWidget { const Recipient({ super.key, + required this.walletId, required this.index, required this.displayNumber, required this.coin, @@ -46,8 +54,10 @@ class Recipient extends ConsumerStatefulWidget { this.onChanged, required this.addAnotherRecipientTapped, required this.sendAllTapped, + this.onMultiRecipientUri, }); + final String walletId; final int index; final int displayNumber; final CryptoCurrency coin; @@ -57,6 +67,10 @@ class Recipient extends ConsumerStatefulWidget { final VoidCallback addAnotherRecipientTapped; final String Function() sendAllTapped; + /// Called with a scanned or pasted payment request for several recipients, + /// which are rejected if this is null. + final void Function(PaymentUriData paymentData)? onMultiRecipientUri; + @override ConsumerState createState() => _RecipientState(); } @@ -70,27 +84,87 @@ class _RecipientState extends ConsumerState { bool get isSingle => widget.remove == null; - void _updateRecipientData() { + RecipientId get _id => (walletId: widget.walletId, index: widget.index); + + void _updateRecipientData({String? label}) { final address = addressController.text; final amount = ref .read(pAmountFormatter(widget.coin)) .tryParseEditable(amountController.text); + final previous = ref.read(pRecipient(_id)); - ref.read(pRecipient(widget.index).notifier).state = ( + ref.read(pRecipient(_id).notifier).state = ( address: address, amount: amount, + // A payment request's name only applies to the address it gave. + label: label ?? (previous?.address == address ? previous?.label : null), ); widget.onChanged?.call(); } + /// Fills in the address, and any amount and name, from scanned, pasted, or + /// typed [input], which may be a payment request. + void _applyAddressInput(String input) { + final paymentData = AddressUtils.parsePaymentUri( + input, + logging: Logging.instance, + allowMultipleRecipients: widget.onMultiRecipientUri != null, + ); + + if (paymentData != null && + paymentData.coin?.uriScheme == widget.coin.uriScheme) { + if (paymentData.isMultiRecipient) { + // Keep this recipient as it was, and let the screen offer to replace + // every recipient with the request's. + addressController.text = ref.read(pRecipient(_id))?.address ?? ""; + setState(() { + _addressIsEmpty = addressController.text.isEmpty; + }); + widget.onMultiRecipientUri!(paymentData); + return; + } + + addressController.text = paymentData.address.trim(); + + if (paymentData.amount != null) { + final amount = Amount.tryParseCanonicalAmount( + paymentData.amount!, + fractionDigits: widget.coin.fractionDigits, + truncateOverprecision: true, + ); + if (amount != null) { + amountController.text = ref + .read(pAmountFormatter(widget.coin)) + .formatEditable(amount); + } else { + amountController.clear(); + } + } + + setState(() { + _addressIsEmpty = addressController.text.isEmpty; + }); + _updateRecipientData(label: paymentData.label); + } else { + if (addressController.text != input) { + addressController.text = input; + } + + setState(() { + _addressIsEmpty = addressController.text.isEmpty; + }); + _updateRecipientData(); + } + } + void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { Amount? cryptoAmount = ref .read(pAmountFormatter(widget.coin)) .tryParseEditable(amountController.text); if (cryptoAmount != null) { - if (ref.read(pRecipient(widget.index))?.amount != null && - ref.read(pRecipient(widget.index))?.amount == cryptoAmount) { + if (ref.read(pRecipient(_id))?.amount != null && + ref.read(pRecipient(_id))?.amount == cryptoAmount) { return; } @@ -121,49 +195,25 @@ class _RecipientState extends ConsumerState { await Future.delayed(const Duration(milliseconds: 75)); } - final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - - Logging.instance.d("qrResult content: ${qrResult.rawContent}"); - - if (qrResult.rawContent == null) return; - - final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent!, - logging: Logging.instance, - ); + if (Util.isDesktop) { + if (!mounted) return; + final qrCodeData = await showDialog( + context: context, + builder: (context) => const QrCodeScannerDialog(), + ); + if (qrCodeData == null || !mounted) return; - Logging.instance.d("qrResult parsed: $paymentData"); + _applyAddressInput(qrCodeData.trim()); + return; + } - if (paymentData != null && - paymentData.coin?.uriScheme == widget.coin.uriScheme) { - // auto fill address + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - addressController.text = paymentData.address.trim(); + Logging.instance.d("qrResult content: ${qrResult.rawContent}"); - // autofill amount field - if (paymentData.amount != null) { - final amount = Amount.tryParseCanonicalAmount( - paymentData.amount!, - fractionDigits: widget.coin.fractionDigits, - truncateOverprecision: true, - ); - if (amount != null) { - amountController.text = ref - .read(pAmountFormatter(widget.coin)) - .formatEditable(amount); - } else { - amountController.clear(); - } - } - } else { - addressController.text = qrResult.rawContent!.trim(); - } + if (qrResult.rawContent == null || !mounted) return; - setState(() { - _addressIsEmpty = addressController.text.isEmpty; - }); - - _updateRecipientData(); + _applyAddressInput(qrResult.rawContent!.trim()); } on PlatformException catch (e, s) { if (mounted) { try { @@ -195,13 +245,13 @@ class _RecipientState extends ConsumerState { amountController = TextEditingController(); // baseController = TextEditingController(); - final amount = ref.read(pRecipient(widget.index))?.amount; + final amount = ref.read(pRecipient(_id))?.amount; if (amount != null) { amountController.text = ref .read(pAmountFormatter(widget.coin)) .formatEditable(amount); } - addressController.text = ref.read(pRecipient(widget.index))?.address ?? ""; + addressController.text = ref.read(pRecipient(_id))?.address ?? ""; _addressIsEmpty = addressController.text.isEmpty; @@ -235,6 +285,19 @@ class _RecipientState extends ConsumerState { localeServiceChangeNotifierProvider.select((value) => value.locale), ); listenForAmountRelocalization(ref.listen, controllers: [amountController]); + // Keep the amount, rather than the number typed, when the unit changes. + ref.listen(pAmountFormatter(widget.coin), ( + previous, + next, + ) { + final amount = ref.read(pRecipient(_id))?.amount; + if (previous?.unit != next.unit && amount != null) { + amountController.text = next.formatEditable(amount); + } + }); + + final label = ref.watch(pRecipient(_id).select((e) => e?.label)); + final address = addressController.text.trim(); return RoundedContainer( color: Colors.transparent, @@ -246,10 +309,15 @@ class _RecipientState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - isSingle ? "Send to" : "Recipient ${widget.displayNumber}", - style: STextStyles.smallMed12(context), - textAlign: TextAlign.left, + Flexible( + child: Text( + isSingle + ? "Send to" + : label ?? "Recipient ${widget.displayNumber}", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + ), ), CustomTextButton( text: isSingle ? "Add another recipient" : "Remove", @@ -272,7 +340,14 @@ class _RecipientState extends ConsumerState { enableSuggestions: false, focusNode: addressFocusNode, style: STextStyles.field(context), - onChanged: (_) { + onChanged: (newValue) { + final previous = ref.read(pRecipient(_id))?.address ?? ""; + // More than one character at once was pasted, and may be a + // payment request. + if ((newValue.length - previous.length).abs() > 1) { + _applyAddressInput(newValue.trim()); + return; + } _updateRecipientData(); setState(() { _addressIsEmpty = addressController.text.isEmpty; @@ -334,14 +409,7 @@ class _RecipientState extends ConsumerState { ); } - addressController.text = content.trim(); - - setState(() { - _addressIsEmpty = - addressController.text.isEmpty; - }); - - _updateRecipientData(); + _applyAddressInput(content.trim()); } }, child: _addressIsEmpty @@ -364,6 +432,16 @@ class _RecipientState extends ConsumerState { ), ), ), + if (address.isNotEmpty && !widget.coin.validateAddress(address)) + Padding( + padding: const EdgeInsets.only(left: 12, top: 4), + child: Text( + "Invalid address", + style: STextStyles.label(context).copyWith( + color: Theme.of(context).extension()!.textError, + ), + ), + ), SizedBox(height: isSingle ? 12 : 8), if (isSingle) Row( diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 34dbc6d08..5f51698f2 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,8 @@ 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 'frost_ms/recipient.dart'; +import 'sub_widgets/multi_recipient.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; @@ -148,6 +151,11 @@ class _SendViewState extends ConsumerState { Amount? _cachedAmountToSend; String? _address; + /// The [pRecipient] index of each recipient's form while sending to more + /// than one recipient, otherwise null. + List? _recipientIndexes; + int _greatestRecipientIndex = 0; + bool _addressToggleFlag = false; bool _isFiroExWarningDisplayed = false; @@ -157,8 +165,161 @@ class _SendViewState extends ConsumerState { Set selectedUTXOs = {}; + bool get _supportsMultiRecipient => + ref.read(pWallets).getWallet(walletId).supportsMultiRecipient; + + RecipientId _recipientId(int index) => (walletId: walletId, index: index); + + /// The recipients while sending to more than one, otherwise null. + List? get _multiRecipients => _recipientIndexes + ?.map( + (index) => + ref.read(pRecipient(_recipientId(index))) ?? + (address: "", amount: null, label: null), + ) + .toList(); + + /// Applies a payment request for several recipients scanned, pasted, or + /// typed into the address field. + void _applyMultiRecipientUri(PaymentUriData paymentData) { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + // Clear the previous payment, so Preview can't send it in place of the + // rejected request. + _address = ""; + sendToController.text = ""; + cryptoAmountController.text = ""; + ref.read(pSendAmount.notifier).state = null; + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = false; + }); + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } + _setOpReturnData(null); + _setMultiRecipients(recipients); + } + + /// Offers to replace the recipients with those of a payment request for + /// several recipients scanned or pasted into one recipient's form. + Future _replaceMultiRecipients(PaymentUriData paymentData) async { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + final replace = await confirmReplaceRecipients( + context, + count: recipients.length, + ); + if (!replace || !mounted) { + return; + } + + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } + _setMultiRecipients(recipients); + } + + void _addRecipientTapped() { + _setMultiRecipients([ + (address: _address ?? "", amount: ref.read(pSendAmount), label: null), + (address: "", amount: null, label: null), + ]); + } + + /// Replaces the address field, or any recipient forms, with a form for each + /// of [recipients]. + void _setMultiRecipients(List recipients) { + _clearRecipientForms(); + _recipientIndexes = [ + for (final recipient in recipients) _newRecipientForm(recipient), + ]; + _address = null; + sendToController.text = ""; + setState(() { + _addressToggleFlag = false; + }); + _multiRecipientsChanged(); + } + + int _newRecipientForm(RecipientData? recipient) { + final index = ++_greatestRecipientIndex; + ref.read(pRecipient(_recipientId(index)).notifier).state = recipient; + return index; + } + + void _clearRecipientForms() { + for (final index in _recipientIndexes ?? const []) { + ref.read(pRecipient(_recipientId(index)).notifier).state = null; + } + _recipientIndexes = null; + } + + void _addRecipientForm() { + setState(() { + _recipientIndexes!.add(_newRecipientForm(null)); + }); + _multiRecipientsChanged(); + } + + void _removeRecipientForm(int index) { + ref.read(pRecipient(_recipientId(index)).notifier).state = null; + _recipientIndexes!.remove(index); + + if (_recipientIndexes!.length == 1) { + // Back to the form for a single recipient. + final recipient = _multiRecipients!.single; + _clearRecipientForms(); + _address = recipient.address; + sendToController.text = recipient.address; + cryptoAmountController.text = recipient.amount == null + ? "" + : ref.read(pAmountFormatter(coin)).formatEditable(recipient.amount!); + ref.read(pSendAmount.notifier).state = recipient.amount; + setState(() { + _addressToggleFlag = recipient.address.isNotEmpty; + }); + _setValidAddressProviders(_address); + } else { + setState(() {}); + _multiRecipientsChanged(); + } + } + + /// Shows the total of the recipients' amounts as the amount to send. + void _multiRecipientsChanged() { + final total = _multiRecipients!.total; + cryptoAmountController.text = total == null + ? "" + : ref.read(pAmountFormatter(coin)).formatEditable(total); + ref.read(pSendAmount.notifier).state = total; + _setValidAddressProviders(_address); + } + void _applyUri(PaymentUriData paymentData) { try { + if (paymentData.isMultiRecipient) { + _applyMultiRecipientUri(paymentData); + return; + } + _clearRecipientForms(); + // auto fill address _address = paymentData.address.trim(); @@ -270,6 +431,7 @@ class _SendViewState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( content, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (paymentData != null && @@ -338,6 +500,7 @@ class _SendViewState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (paymentData != null && @@ -509,7 +672,10 @@ class _SendViewState extends ConsumerState { late Amount _currentFee; void _setValidAddressProviders(String? address) { - if (isPaynymSend) { + if (_recipientIndexes != null) { + ref.read(pValidSendToAddress.notifier).state = _multiRecipients! + .isValidFor(coin); + } else if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; } else { final wallet = ref.read(pWallets).getWallet(walletId); @@ -886,7 +1052,7 @@ class _SendViewState extends ConsumerState { coinControlEnabled && selectedUTXOs.isEmpty)) { // confirm send all - if (amount == availableBalance) { + if (_recipientIndexes == null && amount == availableBalance) { bool? shouldSendAll; if (mounted) { shouldSendAll = await showDialog( @@ -1095,14 +1261,18 @@ class _SendViewState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, - 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, @@ -1151,6 +1321,7 @@ class _SendViewState extends ConsumerState { txData: txData, walletId: walletId, isPaynymTransaction: isPaynymSend, + recipientLabels: _multiRecipients?.map((e) => e.label).toList(), onSuccess: () { if (mounted) { clearSendForm(); @@ -1216,6 +1387,7 @@ class _SendViewState extends ConsumerState { memoController.text = ""; _address = ""; _addressToggleFlag = false; + _clearRecipientForms(); _setOpReturnData(null); _setValidAddressProviders(""); setState(() {}); @@ -1447,6 +1619,16 @@ class _SendViewState extends ConsumerState { controllers: [cryptoAmountController, baseAmountController], onRelocalized: _cryptoAmountChanged, ); + // Keep the amount, rather than the number typed, when the unit changes. + ref.listen(pAmountFormatter(coin), (previous, next) { + final amount = ref.read(pSendAmount); + if (previous?.unit != next.unit && amount != null) { + // Only the display changes, so keep the send all intent. + final xelisSendAll = _xelisSendAll; + cryptoAmountController.text = next.formatEditable(amount); + _xelisSendAll = xelisSendAll; + } + }); final amountFormatter = ref.watch(pAmountFormatter(coin)); final balType = ref.watch(publicPrivateBalanceStateProvider); @@ -1721,6 +1903,13 @@ class _SendViewState extends ConsumerState { style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), + if (!isPaynymSend && + _recipientIndexes == null && + _supportsMultiRecipient) + CustomTextButton( + text: "Add recipient", + onTap: _addRecipientTapped, + ), // if (coin is Monero) // CustomTextButton( // text: "Use OpenAlias", @@ -1747,7 +1936,19 @@ class _SendViewState extends ConsumerState { readOnly: true, style: STextStyles.fieldLabel(context), ), - if (!isPaynymSend && !isSlatepackMode) + if (_recipientIndexes != null) + RecipientForms( + walletId: walletId, + coin: coin, + indexes: _recipientIndexes!, + onChanged: _multiRecipientsChanged, + onAdd: _addRecipientForm, + onRemove: _removeRecipientForm, + onMultiRecipientUri: _replaceMultiRecipients, + ), + if (!isPaynymSend && + !isSlatepackMode && + _recipientIndexes == null) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1780,6 +1981,8 @@ class _SendViewState extends ConsumerState { AddressUtils.parsePaymentUri( trimmed, logging: Logging.instance, + allowMultipleRecipients: + _supportsMultiRecipient, ); if (parsed != null) { _applyUri(parsed); @@ -1800,7 +2003,8 @@ class _SendViewState extends ConsumerState { _setValidAddressProviders(_address); setState(() { - _addressToggleFlag = newValue.isNotEmpty; + _addressToggleFlag = + sendToController.text.isNotEmpty; }); }, focusNode: _addressFocusNode, @@ -2211,11 +2415,15 @@ class _SendViewState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Amount", + _recipientIndexes == null + ? "Amount" + : "Total", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - if (coin is! Ethereum && coin is! Tezos) + if (coin is! Ethereum && + coin is! Tezos && + _recipientIndexes == null) CustomTextButton( text: _getSendAllTitle( showCoinControl, @@ -2228,6 +2436,7 @@ class _SendViewState extends ConsumerState { ), const SizedBox(height: 8), TextField( + readOnly: _recipientIndexes != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -2296,6 +2505,7 @@ class _SendViewState extends ConsumerState { const SizedBox(height: 8), if (Prefs.instance.externalCalls) TextField( + readOnly: _recipientIndexes != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false diff --git a/lib/pages/send_view/sub_widgets/multi_recipient.dart b/lib/pages/send_view/sub_widgets/multi_recipient.dart new file mode 100644 index 000000000..0f8db8167 --- /dev/null +++ b/lib/pages/send_view/sub_widgets/multi_recipient.dart @@ -0,0 +1,231 @@ +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/util.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/models/tx_data.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/dialogs/basic_dialog.dart'; +import '../frost_ms/recipient.dart'; + +/// 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 RecipientDataListExt on List { + /// The sum of the amounts, or null if any recipient is missing one. + Amount? get total { + Amount? sum; + for (final e in this) { + if (e.amount == null) { + return null; + } + sum = sum == null ? e.amount : sum + e.amount!; + } + return sum; + } + + /// Whether every recipient has a valid address and an amount above zero. + bool isValidFor(CryptoCurrency coin) => every( + (e) => + coin.validateAddress(e.address) && + e.amount != null && + e.amount! > Amount.zero, + ); + + /// One transaction output per recipient. Requires [isValidFor]. + List toTxRecipients(CryptoCurrency coin) => [ + for (final e in this) + TxRecipient( + address: e.address, + amount: e.amount!, + isChange: false, + addressType: coin.getAddressType(e.address)!, + ), + ]; +} + +/// Asks whether to replace the recipients on screen with the [count] +/// recipients of a scanned or pasted payment request. +Future confirmReplaceRecipients( + BuildContext context, { + required int count, +}) async { + final replace = await showDialog( + context: context, + builder: (context) => BasicDialog( + title: "Replace recipients?", + message: + "This payment request has $count recipients. Replace the " + "recipients you have entered with them?", + desktopHeight: double.infinity, + desktopWidth: 450, + leftButton: SecondaryButton( + label: "Cancel", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: "Replace", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ); + return replace ?? false; +} + +/// A form for each recipient of a transaction with several recipients, and a +/// button to add another. +class RecipientForms extends StatelessWidget { + const RecipientForms({ + super.key, + required this.walletId, + required this.coin, + required this.indexes, + required this.onChanged, + required this.onAdd, + required this.onRemove, + required this.onMultiRecipientUri, + }); + + final String walletId; + final CryptoCurrency coin; + + /// The [pRecipient] index of each recipient's form, in order. + final List indexes; + final VoidCallback onChanged; + final VoidCallback onAdd; + final void Function(int index) onRemove; + final void Function(PaymentUriData paymentData) onMultiRecipientUri; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (i, index) in indexes.indexed) + Padding( + key: Key("recipientKey_$index"), + padding: EdgeInsets.only(top: i == 0 ? 0 : 16), + child: Recipient( + walletId: walletId, + index: index, + displayNumber: i + 1, + coin: coin, + onChanged: onChanged, + remove: () => onRemove(index), + addAnotherRecipientTapped: onAdd, + // Send all is not offered with several recipients. + sendAllTapped: () => "", + onMultiRecipientUri: onMultiRecipientUri, + ), + ), + const SizedBox(height: 16), + SecondaryButton( + label: "Add recipient", + width: double.infinity, + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: onAdd, + ), + ], + ); + } +} + +/// Each recipient's address and amount, separated by dividers, for the +/// confirmation screen. +class RecipientAmountList extends ConsumerWidget { + const RecipientAmountList({ + super.key, + required this.coin, + required this.recipients, + this.labels, + required this.labelStyle, + required this.valueStyle, + }); + + final CryptoCurrency coin; + final List recipients; + + /// The payment request's names for [recipients], in the same order. + final List? labels; + 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( + (labels != null && i < labels!.length ? labels![i] : null) ?? + "Recipient ${i + 1}", + style: labelStyle, + ), + const SizedBox(height: 4), + SelectableText(recipients[i].address, style: valueStyle), + 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 66ebcbd46..e9cebc438 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,12 @@ 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/frost_ms/recipient.dart'; +import '../../../../pages/send_view/sub_widgets/multi_recipient.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 +137,11 @@ class _DesktopSendState extends ConsumerState { Amount? _cachedAmountToSend; String? _address; + /// The [pRecipient] index of each recipient's form while sending to more + /// than one recipient, otherwise null. + List? _recipientIndexes; + int _greatestRecipientIndex = 0; + bool _addressToggleFlag = false; bool _isFiroExWarningDisplayed = false; @@ -473,7 +481,7 @@ class _DesktopSendState extends ConsumerState { coinControlEnabled) || (coinControlEnabled && ref.read(desktopUseUTXOs).isEmpty)) { // confirm send all - if (amount == availableBalance) { + if (_recipientIndexes == null && amount == availableBalance) { final bool? shouldSendAll = await showDialog( context: context, useSafeArea: false, @@ -722,14 +730,18 @@ class _DesktopSendState extends ConsumerState { txDataFuture = wallet.prepareSend( txData: TxData( xelisSendAll: coin is Xelis && _xelisSendAll, - 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, @@ -785,6 +797,7 @@ class _DesktopSendState extends ConsumerState { onSuccess: clearSendForm, isPaynymTransaction: isPaynymSend, routeOnSuccessName: DesktopHomeView.routeName, + recipientLabels: _multiRecipients?.map((e) => e.label).toList(), ), ), ), @@ -868,6 +881,7 @@ class _DesktopSendState extends ConsumerState { nonceController.text = ""; _address = ""; _addressToggleFlag = false; + _clearRecipientForms(); _syncFeeAmount(null); _setOpReturnData(null); _setValidAddressProviders(""); @@ -949,6 +963,7 @@ class _DesktopSendState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( qrCodeData, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (paymentData != null && @@ -975,7 +990,10 @@ class _DesktopSendState extends ConsumerState { } void _setValidAddressProviders(String? address) { - if (isPaynymSend) { + if (_recipientIndexes != null) { + ref.read(pValidSendToAddress.notifier).state = _multiRecipients! + .isValidFor(coin); + } else if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; } else { final wallet = ref.read(pWallets).getWallet(walletId); @@ -1007,8 +1025,160 @@ class _DesktopSendState extends ConsumerState { } } + bool get _supportsMultiRecipient => + ref.read(pWallets).getWallet(walletId).supportsMultiRecipient; + + RecipientId _recipientId(int index) => (walletId: walletId, index: index); + + /// The recipients while sending to more than one, otherwise null. + List? get _multiRecipients => _recipientIndexes + ?.map( + (index) => + ref.read(pRecipient(_recipientId(index))) ?? + (address: "", amount: null, label: null), + ) + .toList(); + + /// Applies a payment request for several recipients scanned, pasted, or + /// typed into the address field. + void _applyMultiRecipientUri(PaymentUriData paymentData) { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + // Clear the previous payment, so Preview can't send it in place of the + // rejected request. + _address = ""; + sendToController.text = ""; + cryptoAmountController.text = ""; + ref.read(pSendAmount.notifier).state = null; + _syncFeeAmount(null); + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = false; + }); + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + _note = paymentData.message; + _setOpReturnData(null); + _setMultiRecipients(recipients); + } + + /// Offers to replace the recipients with those of a payment request for + /// several recipients scanned or pasted into one recipient's form. + Future _replaceMultiRecipients(PaymentUriData paymentData) async { + final recipients = parseUriRecipients(paymentData, coin); + if (recipients == null) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid payment request amounts", + context: context, + ); + return; + } + + final replace = await confirmReplaceRecipients( + context, + count: recipients.length, + ); + if (!replace || !mounted) { + return; + } + + _note = paymentData.message; + _setMultiRecipients(recipients); + } + + void _addRecipientTapped() { + _setMultiRecipients([ + (address: _address ?? "", amount: ref.read(pSendAmount), label: null), + (address: "", amount: null, label: null), + ]); + } + + /// Replaces the address field, or any recipient forms, with a form for each + /// of [recipients]. + void _setMultiRecipients(List recipients) { + _clearRecipientForms(); + _recipientIndexes = [ + for (final recipient in recipients) _newRecipientForm(recipient), + ]; + _address = null; + sendToController.text = ""; + setState(() { + _addressToggleFlag = false; + }); + _multiRecipientsChanged(); + } + + int _newRecipientForm(RecipientData? recipient) { + final index = ++_greatestRecipientIndex; + ref.read(pRecipient(_recipientId(index)).notifier).state = recipient; + return index; + } + + void _clearRecipientForms() { + for (final index in _recipientIndexes ?? const []) { + ref.read(pRecipient(_recipientId(index)).notifier).state = null; + } + _recipientIndexes = null; + } + + void _addRecipientForm() { + setState(() { + _recipientIndexes!.add(_newRecipientForm(null)); + }); + _multiRecipientsChanged(); + } + + void _removeRecipientForm(int index) { + ref.read(pRecipient(_recipientId(index)).notifier).state = null; + _recipientIndexes!.remove(index); + + if (_recipientIndexes!.length == 1) { + // Back to the form for a single recipient. + final recipient = _multiRecipients!.single; + _clearRecipientForms(); + _address = recipient.address; + sendToController.text = recipient.address; + cryptoAmountController.text = recipient.amount == null + ? "" + : ref.read(pAmountFormatter(coin)).formatEditable(recipient.amount!); + ref.read(pSendAmount.notifier).state = recipient.amount; + _syncFeeAmount(recipient.amount); + setState(() { + _addressToggleFlag = recipient.address.isNotEmpty; + }); + _setValidAddressProviders(_address); + } else { + setState(() {}); + _multiRecipientsChanged(); + } + } + + /// Shows the total of the recipients' amounts as the amount to send. + void _multiRecipientsChanged() { + final total = _multiRecipients!.total; + cryptoAmountController.text = total == null + ? "" + : ref.read(pAmountFormatter(coin)).formatEditable(total); + ref.read(pSendAmount.notifier).state = total; + _syncFeeAmount(total); + _setValidAddressProviders(_address); + } + void _applyUri(PaymentUriData paymentData) { try { + if (paymentData.isMultiRecipient) { + _applyMultiRecipientUri(paymentData); + return; + } + _clearRecipientForms(); + // auto fill address _address = paymentData.address; sendToController.text = _address!; @@ -1102,6 +1272,7 @@ class _DesktopSendState extends ConsumerState { final paymentData = AddressUtils.parsePaymentUri( content, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { @@ -1377,6 +1548,16 @@ class _DesktopSendState extends ConsumerState { controllers: [cryptoAmountController, baseAmountController], onRelocalized: _cryptoAmountChanged, ); + // Keep the amount, rather than the number typed, when the unit changes. + ref.listen(pAmountFormatter(coin), (previous, next) { + final amount = ref.read(pSendAmount); + if (previous?.unit != next.unit && amount != null) { + // Only the display changes, so keep the send all intent. + final xelisSendAll = _xelisSendAll; + cryptoAmountController.text = next.formatEditable(amount); + _xelisSendAll = xelisSendAll; + } + }); final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. @@ -1633,7 +1814,7 @@ class _DesktopSendState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Amount", + _recipientIndexes == null ? "Amount" : "Total", style: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context) .extension()! @@ -1641,7 +1822,9 @@ class _DesktopSendState extends ConsumerState { ), textAlign: TextAlign.left, ), - if (coin is! Ethereum && coin is! Tezos) + if (coin is! Ethereum && + coin is! Tezos && + _recipientIndexes == null) CustomTextButton( text: _getSendAllTitle( showCoinControl, @@ -1653,6 +1836,7 @@ class _DesktopSendState extends ConsumerState { ), const SizedBox(height: 10), TextField( + readOnly: _recipientIndexes != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -1714,6 +1898,7 @@ class _DesktopSendState extends ConsumerState { if (Prefs.instance.externalCalls) const SizedBox(height: 10), if (Prefs.instance.externalCalls) TextField( + readOnly: _recipientIndexes != null, autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, style: STextStyles.smallMed14(context).copyWith( @@ -1802,22 +1987,43 @@ class _DesktopSendState extends ConsumerState { if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) - Text( - "Send to", - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), - textAlign: TextAlign.left, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Send to", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + if (_recipientIndexes == null && _supportsMultiRecipient) + CustomTextButton( + text: "Add recipient", + onTap: _addRecipientTapped, + ), + ], ), if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 10), + if (_recipientIndexes != null) + RecipientForms( + walletId: walletId, + coin: coin, + indexes: _recipientIndexes!, + onChanged: _multiRecipientsChanged, + onAdd: _addRecipientForm, + onRemove: _removeRecipientForm, + onMultiRecipientUri: _replaceMultiRecipients, + ), if (!isPaynymSend && !((isMimblewimblecoin || isEpiccash) && - ref.watch(pIsSlatepack(widget.walletId)))) + ref.watch(pIsSlatepack(widget.walletId))) && + _recipientIndexes == null) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1848,6 +2054,7 @@ class _DesktopSendState extends ConsumerState { final parsed = AddressUtils.parsePaymentUri( trimmed, logging: Logging.instance, + allowMultipleRecipients: _supportsMultiRecipient, ); if (parsed != null) { _setOpReturnData(parsed.additionalParams['op_return']); @@ -1867,7 +2074,7 @@ class _DesktopSendState extends ConsumerState { _setValidAddressProviders(_address); setState(() { - _addressToggleFlag = newValue.isNotEmpty; + _addressToggleFlag = sendToController.text.isNotEmpty; }); }, focusNode: _addressFocusNode, 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/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart index 935d5ad3a..c39416937 100644 --- a/lib/wallets/wallet/impl/monero_wallet.dart +++ b/lib/wallets/wallet/impl/monero_wallet.dart @@ -13,6 +13,9 @@ class MoneroWallet extends LibMoneroWallet { MoneroWallet(CryptoCurrencyNetwork network) : super(Monero(network), lib_monero_compat.WalletType.monero); + @override + bool get supportsMultiRecipient => true; + @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { if (wallet == null || syncStatus is! lib_monero_compat.SyncedSyncStatus) { diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 8d8b0372e..afc68c318 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -1332,7 +1332,11 @@ abstract class LibMoneroWallet try { final bool sweep; - if (txData.utxos == null) { + if (txData.recipients!.length > 1) { + // Paying several recipients is never a sweep, even when their + // total is the whole balance, as the fee must still be paid. + sweep = false; + } else if (txData.utxos == null) { final balance = await availableBalance; sweep = txData.amount! == balance; } else { @@ -1342,12 +1346,6 @@ abstract class LibMoneroWallet sweep = txData.amount!.raw == totalInputsValue; } - // TODO: test this one day - // cs_monero may not support this yet properly - if (sweep && txData.recipients!.length > 1) { - throw Exception("Send all not supported with multiple recipients"); - } - final List outputs = []; for (final recipient in txData.recipients!) { final output = CsRecipient(recipient.address, recipient.amount.raw); 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/frost_ms/recipient_test.dart b/test/pages/send_view/frost_ms/recipient_test.dart new file mode 100644 index 000000000..72e17ea03 --- /dev/null +++ b/test/pages/send_view/frost_ms/recipient_test.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart' as platform; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart'; +import 'package:stackwallet/providers/global/barcode_scanner_provider.dart'; +import 'package:stackwallet/providers/global/clipboard_provider.dart'; +import 'package:stackwallet/providers/global/locale_provider.dart'; +import 'package:stackwallet/services/locale_service.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/address_utils.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; +import 'package:stackwallet/utilities/clipboard_interface.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/desktop/qr_code_scanner_dialog.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"); +} + +/// The mobile camera scanner, returning [rawContent]. +class _FakeScanner implements BarcodeScannerInterface { + _FakeScanner(this.rawContent); + + final String rawContent; + int scans = 0; + + @override + Future scan({required BuildContext context}) async { + scans++; + return ScanResult(rawContent: rawContent); + } +} + +void main() { + final coin = _TestMonero(); + const RecipientId id = (walletId: "wallet", index: 1); + + Amount xmr(String value) => Amount.tryParseCanonicalAmount( + value, + fractionDigits: coin.fractionDigits, + )!; + + late StateProvider unit; + late FakeClipboard clipboard; + late ProviderContainer container; + late List multiRecipientUris; + late _FakeScanner scanner; + + /// Lays widgets out for desktop or mobile on any host platform. + void useLayout({required bool desktop}) { + Util.layoutPlatform = platform.FakePlatform( + operatingSystem: desktop ? "linux" : "android", + ); + Util.screenWidth = null; + Util.isIpad = false; + } + + setUp(() { + final previousPlatform = Util.layoutPlatform; + final previousWidth = Util.screenWidth; + final previousIsIpad = Util.isIpad; + addTearDown(() { + Util.layoutPlatform = previousPlatform; + Util.screenWidth = previousWidth; + Util.isIpad = previousIsIpad; + }); + useLayout(desktop: false); + + scanner = _FakeScanner("monero:address7?tx_amount=0.5&recipient_name=Eve"); + unit = StateProvider((_) => AmountUnit.normal); + clipboard = FakeClipboard(); + multiRecipientUris = []; + container = ProviderContainer( + overrides: [ + pAmountUnit.overrideWithProvider( + (_) => Provider((ref) => ref.watch(unit)), + ), + pAmountFormatter.overrideWithProvider( + (coin) => Provider( + (ref) => AmountFormatter( + unit: ref.watch(pAmountUnit(coin)), + locale: "en_US", + coin: coin, + maxDecimals: coin.fractionDigits, + ), + ), + ), + localeServiceChangeNotifierProvider.overrideWithProvider( + ChangeNotifierProvider((_) => LocaleService()), + ), + pClipboard.overrideWithValue(clipboard), + pBarcodeScanner.overrideWithValue(scanner), + ], + ); + }); + + tearDown(() => container.dispose()); + + RecipientData? recipient() => container.read(pRecipient(id)); + + Future pumpRecipient( + WidgetTester tester, { + RecipientData? initial, + bool single = false, + bool handlesMultiRecipientUris = true, + }) async { + container.read(pRecipient(id).notifier).state = initial; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: SingleChildScrollView( + child: Recipient( + walletId: id.walletId, + index: id.index, + displayNumber: 1, + coin: coin, + remove: single ? null : () {}, + addAnotherRecipientTapped: () {}, + sendAllTapped: () => "", + onMultiRecipientUri: handlesMultiRecipientUris + ? multiRecipientUris.add + : null, + ), + ), + ), + ), + ), + ); + } + + Finder addressField() => find.byKey(const Key("sendViewAddressFieldKey")); + Finder amountField() => + find.byKey(const Key("amountInputFieldCryptoTextFieldKey")); + String text(WidgetTester tester, Finder finder) => + tester.widget(finder).controller!.text; + + testWidgets("shows the request's name until the address is changed", ( + tester, + ) async { + await pumpRecipient( + tester, + initial: (address: "address0", amount: xmr("1"), label: "Alice"), + ); + + expect(find.text("Alice"), findsOneWidget); + expect(find.text("Invalid address"), findsNothing); + + await tester.enterText(addressField(), "bad"); + await tester.pump(); + + expect(find.text("Alice"), findsNothing); + expect(find.text("Recipient 1"), findsOneWidget); + expect(find.text("Invalid address"), findsOneWidget); + expect(recipient(), (address: "bad", amount: xmr("1"), label: null)); + }); + + testWidgets("keeps the amount when the display unit changes", (tester) async { + container.read(unit.notifier).state = AmountUnit.milli; + await pumpRecipient( + tester, + initial: (address: "address0", amount: xmr("1"), label: null), + ); + expect(text(tester, amountField()), "1000"); + + container.read(unit.notifier).state = AmountUnit.normal; + await tester.pump(); + expect(text(tester, amountField()), "1"); + + // Editing the recipient afterwards must not read "1000" as XMR. + await tester.enterText(addressField(), "address1"); + await tester.pump(); + expect(recipient()!.amount, xmr("1")); + }); + + testWidgets("fills the address, amount, and name from a pasted request", ( + tester, + ) async { + await pumpRecipient(tester); + await clipboard.setData( + const ClipboardData( + text: "monero:address5?tx_amount=0.25&recipient_name=Bob", + ), + ); + + await tester.tap( + find.byKey(const Key("sendViewPasteAddressFieldButtonKey")), + ); + await tester.pumpAndSettle(); + + expect(text(tester, addressField()), "address5"); + expect(text(tester, amountField()), "0.25"); + expect(find.text("Bob"), findsOneWidget); + expect(recipient(), ( + address: "address5", + amount: xmr("0.25"), + label: "Bob", + )); + }); + + testWidgets("fills in a request typed or pasted into the address field", ( + tester, + ) async { + await pumpRecipient(tester); + + await tester.enterText(addressField(), "monero:address5?tx_amount=2"); + await tester.pump(); + + expect(text(tester, addressField()), "address5"); + expect(text(tester, amountField()), "2"); + expect(recipient()!.amount, xmr("2")); + }); + + const multiRecipientUri = + "monero:address1;address2?tx_amount=1;2&recipient_name=A;B"; + + testWidgets("hands a request for several recipients to the screen", ( + tester, + ) async { + await pumpRecipient( + tester, + initial: (address: "address0", amount: xmr("1"), label: null), + ); + + await tester.enterText(addressField(), multiRecipientUri); + await tester.pump(); + + expect(multiRecipientUris.single.recipients.length, 2); + // The recipient is left as it was until the screen replaces it. + expect(text(tester, addressField()), "address0"); + expect(recipient(), (address: "address0", amount: xmr("1"), label: null)); + }); + + testWidgets("rejects requests for several recipients without a handler", ( + tester, + ) async { + await pumpRecipient(tester, single: true, handlesMultiRecipientUris: false); + + await tester.enterText(addressField(), multiRecipientUri); + await tester.pump(); + + expect(find.text("Send to"), findsOneWidget); + expect(find.text("Invalid address"), findsOneWidget); + expect(recipient()!.address, multiRecipientUri); + }); + + testWidgets("clearing the address drops the name", (tester) async { + await pumpRecipient( + tester, + initial: (address: "address0", amount: xmr("1"), label: "Alice"), + ); + + await tester.tap( + find.byKey(const Key("sendViewClearAddressFieldButtonKey")), + ); + await tester.pump(); + + expect(text(tester, addressField()), ""); + expect(find.text("Recipient 1"), findsOneWidget); + expect(recipient(), (address: "", amount: xmr("1"), label: null)); + }); + + testWidgets("scans with the mobile scanner on mobile", (tester) async { + await pumpRecipient(tester); + + await tester.tap(find.byKey(const Key("sendViewScanQrButtonKey"))); + await tester.pumpAndSettle(); + + expect(scanner.scans, 1); + expect(find.byType(QrCodeScannerDialog), findsNothing); + expect(recipient(), ( + address: "address7", + amount: xmr("0.5"), + label: "Eve", + )); + }); + + test("keeps each wallet's recipients separate", () { + const RecipientId other = (walletId: "other", index: 1); + container.read(pRecipient(id).notifier).state = ( + address: "address0", + amount: null, + label: null, + ); + + expect(container.read(pRecipient(other)), isNull); + }); +} diff --git a/test/pages/send_view/sub_widgets/multi_recipient_test.dart b/test/pages/send_view/sub_widgets/multi_recipient_test.dart new file mode 100644 index 000000000..dd7ea2d26 --- /dev/null +++ b/test/pages/send_view/sub_widgets/multi_recipient_test.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart' as platform; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/multi_recipient.dart'; +import 'package:stackwallet/providers/global/locale_provider.dart'; +import 'package:stackwallet/services/locale_service.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/address_utils.dart'; +import 'package:stackwallet/utilities/amount/amount.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 coin = _TestMonero(); + + 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 {}, + ); + + Amount xmr(String value) => Amount.tryParseCanonicalAmount( + value, + fractionDigits: coin.fractionDigits, + )!; + + test("parses each recipient's amount in the coin's units", () { + final recipients = parseUriRecipients(uri(["1.5", "0.000000000001"]), coin); + + expect(recipients, [ + (address: "address0", amount: xmr("1.5"), label: "Alice"), + (address: "address1", amount: xmr("0.000000000001"), label: null), + ]); + expect(recipients!.total, xmr("1.500000000001")); + }); + + test("truncates amounts more precise than the coin", () { + final recipients = parseUriRecipients(uri(["0.1234567890129", "1"]), coin); + + expect(recipients!.first.amount, xmr("0.123456789012")); + }); + + test("rejects missing and zero amounts", () { + expect(parseUriRecipients(uri(["1", null]), coin), isNull); + expect(parseUriRecipients(uri(["1", "0"]), coin), isNull); + expect(parseUriRecipients(uri(["1", "0.0000000000001"]), coin), isNull); + }); + + test("builds one transaction output per recipient", () { + final recipients = parseUriRecipients(uri(["1", "2"]), coin)!; + + expect(recipients.isValidFor(coin), isTrue); + + final outputs = recipients.toTxRecipients(coin); + expect(outputs.map((e) => e.address), ["address0", "address1"]); + expect(outputs.map((e) => e.amount), [xmr("1"), xmr("2")]); + expect(outputs.map((e) => e.isChange), [false, false]); + expect(outputs.map((e) => e.addressType), [ + AddressType.cryptonote, + AddressType.cryptonote, + ]); + }); + + test("is invalid unless every recipient has an address and amount", () { + final RecipientData valid = ( + address: "address0", + amount: xmr("1"), + label: null, + ); + + expect([valid, valid].isValidFor(coin), isTrue); + for (final RecipientData invalid in [ + (address: "bad", amount: xmr("2"), label: null), + (address: "address1", amount: null, label: null), + (address: "address1", amount: xmr("0"), label: null), + ]) { + expect([valid, invalid].isValidFor(coin), isFalse); + } + }); + + test("has no total while an amount is missing", () { + expect( + [ + (address: "address0", amount: xmr("1"), label: null), + (address: "address1", amount: null, label: null), + ].total, + isNull, + ); + }); + + late ProviderContainer container; + + /// Lays widgets out for desktop or mobile on any host platform. + void useLayout({required bool desktop}) { + Util.layoutPlatform = platform.FakePlatform( + operatingSystem: desktop ? "linux" : "android", + ); + Util.screenWidth = null; + Util.isIpad = false; + } + + setUp(() { + final previousPlatform = Util.layoutPlatform; + final previousWidth = Util.screenWidth; + final previousIsIpad = Util.isIpad; + addTearDown(() { + Util.layoutPlatform = previousPlatform; + Util.screenWidth = previousWidth; + Util.isIpad = previousIsIpad; + }); + + container = ProviderContainer( + overrides: [ + pAmountUnit.overrideWithProvider( + (_) => Provider((_) => AmountUnit.normal), + ), + pAmountFormatter.overrideWithProvider( + (coin) => Provider( + (_) => AmountFormatter( + unit: AmountUnit.normal, + locale: "en_US", + coin: coin, + maxDecimals: coin.fractionDigits, + ), + ), + ), + localeServiceChangeNotifierProvider.overrideWithProvider( + ChangeNotifierProvider((_) => LocaleService()), + ), + ], + ); + }); + + tearDown(() => container.dispose()); + + Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold(body: SingleChildScrollView(child: child)), + ), + ), + ); + + testWidgets("confirmation list shows names and amounts", (tester) async { + await pump( + tester, + RecipientAmountList( + coin: coin, + recipients: parseUriRecipients( + uri(["1", "2.50", "0.000000000001"]), + coin, + )!.toTxRecipients(coin), + labels: const ["Alice", null, null], + labelStyle: const TextStyle(), + valueStyle: const TextStyle(), + ), + ); + + expect(find.text("Alice"), findsOneWidget); + expect(find.text("Recipient 2"), findsOneWidget); + expect(find.text("Recipient 3"), findsOneWidget); + 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]) { + final platform = isDesktop ? "desktop" : "mobile"; + + testWidgets("lists a form per recipient on $platform", (tester) async { + useLayout(desktop: isDesktop); + for (final (index, address) in [(3, "address0"), (7, "bad")]) { + container + .read(pRecipient((walletId: "wallet", index: index)).notifier) + .state = ( + address: address, + amount: xmr("1"), + label: null, + ); + } + int added = 0; + final List removed = []; + + await pump( + tester, + RecipientForms( + walletId: "wallet", + coin: coin, + indexes: const [3, 7], + onChanged: () {}, + onAdd: () => added++, + onRemove: removed.add, + onMultiRecipientUri: (_) {}, + ), + ); + + expect(find.text("Recipient 1"), findsOneWidget); + expect(find.text("Recipient 2"), findsOneWidget); + expect(find.widgetWithText(TextField, "address0"), findsOneWidget); + expect(find.widgetWithText(TextField, "bad"), findsOneWidget); + expect(find.text("Invalid address"), findsOneWidget); + + await tester.tap(find.text("Add recipient")); + await tester.tap(find.text("Remove", findRichText: true).last); + await tester.pump(); + + expect(added, 1); + expect(removed, [7]); + }); + } + + for (final (button, replace) in [("Replace", true), ("Cancel", false)]) { + testWidgets("asks before replacing recipients ($button)", (tester) async { + useLayout(desktop: false); + bool? result; + await pump( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () async => + result = await confirmReplaceRecipients(context, count: 3), + child: const Text("open"), + ), + ), + ); + + await tester.tap(find.text("open")); + await tester.pumpAndSettle(); + expect(find.text("Replace recipients?"), findsOneWidget); + expect(find.textContaining("has 3 recipients"), findsOneWidget); + + await tester.tap(find.text(button)); + await tester.pumpAndSettle(); + expect(result, replace); + }); + } +}