mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-18 05:51:34 +02:00
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:
co-authored by
Omar Hatem
parent
a56ae8e4ab
commit
025fc9154c
@@ -107,6 +107,8 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
|
||||
|
||||
bool _effectsInstalled = false;
|
||||
BuildContext? loadingBottomSheetContext;
|
||||
bool _justHandledPasteButton = false;
|
||||
String _lastHandledAddress = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -385,22 +387,27 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPushPasteButton: (context) async {
|
||||
output.resetParsedAddress();
|
||||
await output.fetchParsedAddress(context);
|
||||
_justHandledPasteButton = true;
|
||||
try {
|
||||
output.resetParsedAddress();
|
||||
await output.fetchParsedAddress(context);
|
||||
|
||||
final address =
|
||||
output.isParsedAddress ? output.extractedAddress : output.address;
|
||||
final address =
|
||||
output.isParsedAddress ? output.extractedAddress : output.address;
|
||||
|
||||
await _handlePaymentFlow(
|
||||
address,
|
||||
PaymentRequest(
|
||||
await _handlePaymentFlow(
|
||||
address,
|
||||
cryptoAmountController.text,
|
||||
noteController.text,
|
||||
"",
|
||||
null,
|
||||
),
|
||||
);
|
||||
PaymentRequest(
|
||||
address,
|
||||
cryptoAmountController.text,
|
||||
noteController.text,
|
||||
"",
|
||||
null,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_justHandledPasteButton = false;
|
||||
}
|
||||
},
|
||||
onPushAddressBookButton: (context) async {
|
||||
output.resetParsedAddress();
|
||||
@@ -419,6 +426,7 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
controller: extractedAddressController,
|
||||
readOnly: true,
|
||||
enableInteractiveSelection: false,
|
||||
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -768,7 +776,42 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
|
||||
|
||||
addressFocusNode.addListener(() async {
|
||||
if (!addressFocusNode.hasFocus && addressController.text.isNotEmpty) {
|
||||
final current = addressController.text.trim();
|
||||
if (current.isEmpty) return;
|
||||
if (_justHandledPasteButton || _lastHandledAddress == current) return;
|
||||
|
||||
await output.fetchParsedAddress(context);
|
||||
|
||||
// If it's a URI with params, go through URI flow
|
||||
if (current.contains('=')) {
|
||||
try {
|
||||
final uri = Uri.parse(current);
|
||||
_lastHandledAddress = current;
|
||||
await _handlePaymentFlow(
|
||||
uri.toString(),
|
||||
PaymentRequest.fromUri(uri),
|
||||
);
|
||||
return;
|
||||
} catch (_) {
|
||||
// fall through to plain address
|
||||
}
|
||||
}
|
||||
|
||||
final parsedAddress = output.isParsedAddress
|
||||
? output.extractedAddress
|
||||
: output.address;
|
||||
|
||||
_lastHandledAddress = current;
|
||||
await _handlePaymentFlow(
|
||||
parsedAddress,
|
||||
PaymentRequest(
|
||||
parsedAddress,
|
||||
cryptoAmountController.text,
|
||||
noteController.text,
|
||||
"",
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -138,6 +138,12 @@ class OtherSettingsPage extends BasePage {
|
||||
title: '[dev] *QR tools',
|
||||
handler: (context) => Navigator.of(context).pushNamed(Routes.devQRTools),
|
||||
),
|
||||
if (FeatureFlag.hasDevOptions)
|
||||
SettingsCellWithArrow(
|
||||
title: '[dev] exchange provider logs',
|
||||
handler: (BuildContext context) =>
|
||||
Navigator.of(context).pushNamed(Routes.devExchangeProviderLogs),
|
||||
),
|
||||
Spacer(),
|
||||
SettingsVersionCell(
|
||||
title: S.of(context).version(_otherSettingsViewModel.currentVersion),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user