Pay Anything Flow Enhancements (#2564)

* fix: update Bitcoin and Litecoin Bech32 address patterns for improved detection for pay anything flow

* feat: add Litecoin MWEB address detection patterns for pay anything flow

* feat: Add support for ERC-681 payment scheme to QR and link to exchange send from external QR

* feat: Introduce TokenUtilities to centralize token management across viewmodels

- Added TokenUtilities class to centralize token-related operations for EVM, Solana, and Tron tokens.
- Refactored existing methods in ExchangeTradeViewModel and SendViewModel to utilize TokenUtilities.
- Updated PaymentRequest to include contractAddress.
- Enhanced payment QR scanning to fetch token details for QRs with contract addresses - ERC-681 QR scheme

* fix: Prefill amount with other data when app is brought up via payment deeplink on QR scan

* fix: Reduce trade not created errors WIP

* feat: Add exchange provider logging functionality

* feat: Enhance ERC681URI with amount normalization, prevents parsing issues with various input formats for amounts

* - Use regex from address validator for btc/ltc manual checks
- Add check for solana addresses
- Handle failures in normalizeToInteger method
- fix issue with address only being added when there's an amount

* feat: Trigger pay anything flow when address is pasted via ctrl v or paste input

* fix: remove duplicate registration

* fix: disable text selection in SendCard address field

* feat: Prevent swap option from showing for Mweb and Silent Payment addresses in Pay Anything flow

* refactor: remove notes widget and controllers from swap confirmation bottomsheet

* fix: Issue with tap send all button not responsive, and updateing fiat amount, after wallet syncing is complete if it was previously tapped while wallet was syncing

* fix: Resolve excessive decimal places error with input sanitization and formatting for amount entry

* fix: Add check for already usable amount format to prevent errors with Nano QR scanning

* fix: Improve fiat input handling by preventing unnecessary updates during user input

* fix: Handle potential null case for setReceiveAmountFromFiat

* Update lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
David Adegoke
2025-10-11 04:20:09 +03:00
committed by GitHub
co-authored by Omar Hatem
parent a56ae8e4ab
commit 025fc9154c
7 changed files with 179 additions and 55 deletions
@@ -65,6 +65,21 @@ class _PaymentConfirmationContent extends StatelessWidget {
final VoidCallback onChangeWallet;
final VoidCallback onSwap;
/// Checks if the given address is a MWEB or SP (Silent Payment) address
bool _isMwebOrSpAddress(String address) {
if (address.isEmpty) return false;
final lowerAddress = address.toLowerCase();
// Check for MWEB addresses (Litecoin MWEB addresses start with "ltcmweb1")
if (lowerAddress.startsWith('ltcmweb1')) return true;
// Check for Silent Payment addresses (Bitcoin SP addresses start with "sp1", "tsp1")
if (lowerAddress.startsWith('sp1') || lowerAddress.startsWith('tsp1')) return true;
return false;
}
@override
Widget build(BuildContext context) {
return Observer(
@@ -78,6 +93,9 @@ class _PaymentConfirmationContent extends StatelessWidget {
final hasAtLeastOneWallet =
paymentFlowResult.type == PaymentFlowType.singleWallet || hasMultipleWallets;
final isMwebOrSpAddress =
_isMwebOrSpAddress(paymentFlowResult.addressDetectionResult?.address ?? '');
return Container(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
@@ -114,13 +132,15 @@ class _PaymentConfirmationContent extends StatelessWidget {
),
const SizedBox(height: 72),
if (hasAtLeastOneWallet) ...[
PrimaryButton(
onPressed: onSwap,
text: '${S.current.swap} $currentWalletName',
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(height: 10),
if (!isMwebOrSpAddress) ...[
PrimaryButton(
onPressed: onSwap,
text: '${S.current.swap} $currentWalletName',
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(height: 10),
],
PrimaryButton(
onPressed: hasMultipleWallets ? onSelectWallet : onChangeWallet,
text: S.current.switch_wallet,
@@ -136,16 +156,19 @@ class _PaymentConfirmationContent extends StatelessWidget {
textColor: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(height: 10),
PrimaryButton(
onPressed: onSwap,
text: '${S.current.swap} $currentWalletName',
color: hasAtLeastOneWallet
? Theme.of(context).colorScheme.surfaceContainer
: Theme.of(context).colorScheme.primary,
textColor: hasAtLeastOneWallet
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onPrimary,
),
if (!isMwebOrSpAddress) ...[
PrimaryButton(
onPressed: onSwap,
text: '${S.current.swap} $currentWalletName',
color: hasAtLeastOneWallet
? Theme.of(context).colorScheme.surfaceContainer
: Theme.of(context).colorScheme.primary,
textColor: hasAtLeastOneWallet
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onPrimary,
),
const SizedBox(height: 10),
],
const SizedBox(height: 32),
],
],
@@ -1,5 +1,3 @@
import 'dart:developer';
import 'package:cake_wallet/core/amount_validator.dart';
import 'package:cake_wallet/core/auth_service.dart';
import 'package:cake_wallet/di.dart';
@@ -11,8 +9,10 @@ import 'package:cake_wallet/src/widgets/bottom_sheet/swap_details_bottom_sheet.d
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/debounce.dart';
import 'package:cw_core/crypto_amount_format.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cw_core/wallet_type.dart';
@@ -76,14 +76,12 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
late TextEditingController _amountController;
late TextEditingController _amountFiatController;
late TextEditingController _addressController;
late TextEditingController _noteController;
final _receiveAmountDebounce = Debounce(Duration(milliseconds: 500));
final _receiveAmountFiatDebounce = Debounce(Duration(milliseconds: 500));
final FocusNode _amountFocus = FocusNode();
final FocusNode _amountFiatFocus = FocusNode();
final FocusNode _addressFocus = FocusNode();
final FocusNode _noteFocus = FocusNode();
final _formKey = GlobalKey<FormState>();
ReactionDisposer? _receiveAmountReaction;
@@ -94,6 +92,7 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
bool _showingFailureDialog = false;
bool _showingSwapDetailsDialog = false;
bool _isUserTypingFiat = false;
@override
void initState() {
@@ -106,8 +105,6 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
: '0.00');
_amountFiatController =
TextEditingController(text: widget.exchangeViewModel.receiveAmountFiatFormatted);
_noteController =
TextEditingController(text: widget.paymentFlowResult.addressDetectionResult?.note ?? '');
WidgetsBinding.instance.addPostFrameCallback(
(_) => _setUpReactions(
@@ -123,11 +120,9 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
_amountController.dispose();
_amountFiatController.dispose();
_addressController.dispose();
_noteController.dispose();
_amountFocus.dispose();
_amountFiatFocus.dispose();
_addressFocus.dispose();
_noteFocus.dispose();
_receiveAmountReaction?.call();
_receiveAddressReaction?.call();
_tradeStateReaction?.call();
@@ -176,7 +171,23 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
hintText: 'Amount (${detectedCurrencyName})',
focusNode: _amountFocus,
controller: _amountController,
keyboardType: TextInputType.numberWithOptions(decimal: true),
keyboardType: TextInputType.numberWithOptions(decimal: true, signed: false),
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')),
],
onChanged: (value) {
final sanitized = value
.replaceAll(',', '.')
.withMaxDecimals(widget.exchangeViewModel.receiveCurrency.decimals);
if (sanitized != _amountController.text) {
// Update text while preserving a sane cursor position to avoid auto-selection
_amountController.value = _amountController.value.copyWith(
text: sanitized,
selection: TextSelection.collapsed(offset: sanitized.length),
composing: TextRange.empty,
);
}
},
validator: (value) {
return AmountValidator(
isAutovalidate: true,
@@ -260,14 +271,6 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
focusNode: _addressFocus,
controller: _addressController,
),
const SizedBox(height: 8),
SwapConfirmationTextfield(
maxLines: 1,
key: ValueKey('swap_confirmation_bottomsheet_note_textfield_key'),
hintText: 'Transaction Note',
focusNode: _noteFocus,
controller: _noteController,
),
SizedBox(height: 8),
Center(
child: Text(
@@ -303,7 +306,7 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
_receiveAmountFiatReaction =
reaction((_) => exchangeViewModel.receiveAmountFiatFormatted, (String amount) {
if (_amountFiatController.text != amount) {
if (!_isUserTypingFiat && _amountFiatController.text != amount) {
_amountFiatController.text = amount;
}
});
@@ -381,9 +384,14 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
_amountFiatController.addListener(() {
if (_amountFiatController.text != exchangeViewModel.receiveAmountFiatFormatted) {
_isUserTypingFiat = true;
_receiveAmountFiatDebounce.run(() {
exchangeViewModel.loadLimits();
exchangeViewModel.setReceiveAmountFromFiat(fiatAmount: _amountFiatController.text);
// Reset the flag after the debounced operation completes
Future.delayed(Duration(milliseconds: 100), () {
_isUserTypingFiat = false;
});
});
}
});
@@ -394,6 +402,17 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
}
});
_amountFiatFocus.addListener(() {
if (_amountFiatFocus.hasFocus) {
_isUserTypingFiat = true;
} else {
// Reset the flag when user stops focusing on the field
Future.delayed(Duration(milliseconds: 200), () {
_isUserTypingFiat = false;
});
}
});
exchangeViewModel.receiveCurrency = walletTypeToCryptoCurrency(paymentFlowResult.walletType!);
await exchangeViewModel.fetchFiatPrice(exchangeViewModel.receiveCurrency);
@@ -417,6 +436,8 @@ class SwapConfirmationTextfield extends StatelessWidget {
this.maxLines = 1,
this.validator,
this.keyboardType,
this.onChanged,
this.inputFormatters,
});
final FocusNode focusNode;
@@ -427,7 +448,8 @@ class SwapConfirmationTextfield extends StatelessWidget {
final int maxLines;
final String? Function(String?)? validator;
final TextInputType? keyboardType;
final void Function(String)? onChanged;
final List<TextInputFormatter>? inputFormatters;
@override
Widget build(BuildContext context) {
return Container(
@@ -472,6 +494,8 @@ class SwapConfirmationTextfield extends StatelessWidget {
maxLines: maxLines,
validator: validator,
keyboardType: keyboardType,
onChanged: onChanged,
inputFormatters: inputFormatters,
),
],
),