diff --git a/lib/pages/buy_view/buy_form.dart b/lib/pages/buy_view/buy_form.dart index 93b64400d4..f71dc43e3f 100644 --- a/lib/pages/buy_view/buy_form.dart +++ b/lib/pages/buy_view/buy_form.dart @@ -31,6 +31,9 @@ import '../../services/buy/buy_response.dart'; import '../../services/buy/simplex/simplex_api.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_input_formatter.dart'; import '../../utilities/assets.dart'; import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; @@ -80,8 +83,6 @@ class BuyForm extends ConsumerStatefulWidget { } class _BuyFormState extends ConsumerState { - late final CryptoCurrency? coin; - late final ClipboardInterface clipboard; late final TextEditingController _receiveAddressController; @@ -124,6 +125,45 @@ class _BuyFormState extends ConsumerState { // static Decimal maxCrypto = Decimal.parse((10000.00000000).toString()); // static String boundedCryptoTicker = ''; + /// The currency a crypto amount is denominated in: the crypto being bought, + /// which need not be the wallet's coin and is the only source on desktop, + /// where [BuyForm] is always built without one. + CryptoCurrency? get _selectedCryptoCurrency { + final ticker = selectedCrypto?.ticker ?? widget.coin?.ticker; + return ticker == null ? null : AppConfig.getCryptoCurrencyForTicker(ticker); + } + + /// Parse a buy amount string using the active locale's decimal and group + /// separators. Fiat amounts are parsed via [Amount.tryParseFiatString]; + /// crypto amounts go through [pAmountFormatter] so that full crypto + /// precision is preserved. + Decimal? _tryParseBuyAmount(String value) { + if (buyWithFiat) { + return Amount.tryParseFiatString( + value, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + )?.decimal; + } + + final cc = _selectedCryptoCurrency; + if (cc == null) { + // Fail closed: the fiat parser truncates to 2 decimals, which would + // silently zero a small crypto amount. + return null; + } + + return ref.read(pAmountFormatter(cc)).tryParse(value)?.decimal; + } + + AmountInputFormatter _buyAmountInputFormatter() { + final cc = buyWithFiat ? null : _selectedCryptoCurrency; + return AmountInputFormatter( + decimals: buyWithFiat ? 2 : cc?.fractionDigits ?? 8, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + unit: cc == null ? null : ref.read(pAmountUnit(cc)), + ); + } + String _amountOutOfRangeErrorString = ""; void validateAmount() { if (_buyAmountController.text.isEmpty) { @@ -133,7 +173,7 @@ class _BuyFormState extends ConsumerState { return; } - final value = Decimal.tryParse(_buyAmountController.text); + final value = _tryParseBuyAmount(_buyAmountController.text); if (value == null) { setState(() { _amountOutOfRangeErrorString = "Invalid amount"; @@ -396,6 +436,12 @@ class _BuyFormState extends ConsumerState { // } Future previewQuote(SimplexQuote quote) async { + final amount = _tryParseBuyAmount(_buyAmountController.text); + if (amount == null) { + validateAmount(); + return; + } + bool shouldPop = false; unawaited( showDialog( @@ -414,11 +460,11 @@ class _BuyFormState extends ConsumerState { crypto: selectedCrypto!, fiat: selectedFiat!, youPayFiatPrice: buyWithFiat - ? Decimal.parse(_buyAmountController.text) + ? amount : Decimal.parse("100"), // dummy value youReceiveCryptoAmount: buyWithFiat ? Decimal.parse("0.000420282") // dummy value - : Decimal.parse(_buyAmountController.text), // Ternary for this + : amount, id: "id", // anything; we get an ID back receivingAddress: _receiveAddressController.text, buyWithFiat: buyWithFiat, @@ -1013,7 +1059,7 @@ class _BuyFormState extends ConsumerState { decimal: true, ), textAlign: TextAlign.left, - // inputFormatters: [NumericalRangeFormatter()], + inputFormatters: [_buyAmountInputFormatter()], onChanged: (_) { validateAmount(); }, @@ -1123,13 +1169,21 @@ class _BuyFormState extends ConsumerState { final ClipboardData? data = await clipboard .getData(Clipboard.kTextPlain); - final amountString = Decimal.tryParse( - data?.text ?? "", - ); - if (amountString != null) { - _buyAmountController.text = amountString - .toString(); - + final pasted = data?.text ?? ""; + final formatted = _buyAmountInputFormatter() + .formatEditUpdate( + TextEditingValue.empty, + TextEditingValue( + text: pasted, + selection: TextSelection.collapsed( + offset: pasted.length, + ), + ), + ); + if (formatted.text.isNotEmpty && + _tryParseBuyAmount(formatted.text) != + null) { + _buyAmountController.value = formatted; validateAmount(); } }, diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 18b8d5be2e..aeac75efdc 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1277,10 +1277,11 @@ class _SendViewState extends ConsumerState { builder: (_) => TransactionFeeSelectionSheet( walletId: walletId, amount: - (Decimal.tryParse(cryptoAmountController.text) ?? - ref.watch(pSendAmount)?.decimal ?? - Decimal.zero) - .toAmount(fractionDigits: coin.fractionDigits), + ref + .read(pAmountFormatter(coin)) + .tryParse(cryptoAmountController.text) ?? + ref.watch(pSendAmount) ?? + Amount.zeroWith(fractionDigits: coin.fractionDigits), updateChosen: (String fee) { if (fee == "custom") { if (!isCustomFee.value) { diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 6187d4c53a..8988d22ec6 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -267,9 +267,11 @@ class _SolTokenSendViewState extends ConsumerState { final tokenWallet = ref.read(pCurrentSolanaTokenWallet); if (tokenWallet == null) return; - final cryptoAmount = Decimal.tryParse( - cryptoAmountController.text, - )?.toAmount(fractionDigits: tokenWallet.tokenDecimals); + final cryptoAmount = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .tryParse(cryptoAmountController.text) + ?.decimal + .toAmount(fractionDigits: tokenWallet.tokenDecimals); if (cryptoAmount != null) { _amountToSend = cryptoAmount; if (_cachedAmountToSend != null && @@ -1258,16 +1260,29 @@ class _SolTokenSendViewState extends ConsumerState { walletId: walletId, isToken: true, amount: - (Decimal.tryParse( - cryptoAmountController - .text, - ) ?? - Decimal.zero) + ref + .read( + pAmountFormatter( + Solana( + CryptoCurrencyNetwork + .main, + ), + ), + ) + .tryParse( + cryptoAmountController + .text, + ) + ?.decimal .toAmount( fractionDigits: tokenWallet .tokenDecimals, - ), + ) ?? + Amount.zeroWith( + fractionDigits: tokenWallet + .tokenDecimals, + ), updateChosen: (String fee) { setState(() { _calculateFeesFuture = Future( diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 3d30fc5f6a..33ce1d4ce3 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -1200,16 +1200,20 @@ class _TokenSendViewState extends ConsumerState { walletId: walletId, isToken: true, amount: - (Decimal.tryParse( - cryptoAmountController - .text, - ) ?? - Decimal.zero) - .toAmount( - fractionDigits: - tokenContract - .decimals, - ), + ref + .read( + pAmountFormatter(coin), + ) + .tryParse( + cryptoAmountController + .text, + tokenContract: + tokenContract, + ) ?? + Amount.zeroWith( + fractionDigits: + tokenContract.decimals, + ), updateChosen: (String fee) { if (fee == "custom") { if (!isCustomFee.value) { diff --git a/lib/utilities/amount/amount_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart index 2ecd9fe540..998d5c2b5c 100644 --- a/lib/utilities/amount/amount_input_formatter.dart +++ b/lib/utilities/amount/amount_input_formatter.dart @@ -26,11 +26,47 @@ class AmountInputFormatter extends TextInputFormatter { final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; + final grouping = _Grouping.fromPattern( + numberSymbols?.DECIMAL_PATTERN ?? "#,##0.###", + ); + + final canonicalText = _canonicalizeSpaceGrouping( + newValue.text, + groupSeparator, + ); + if (!_hasOnlyAmountCharacters( + canonicalText, + decimalSeparator, + groupSeparator, + )) { + return oldValue; + } + TextEditingValue valueToProcess = newValue.copyWith(text: canonicalText); + if (_isBulkEdit(oldValue.text, newValue.text)) { + final normalized = _normalizeBulkInput( + canonicalText, + decimalSeparator: decimalSeparator, + groupSeparator: groupSeparator, + grouping: grouping, + ); + if (normalized == null) { + return oldValue; + } + + final selectionIndexFromTheRight = + newValue.text.length - newValue.selection.end; + valueToProcess = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed( + offset: max(normalized.length - selectionIndexFromTheRight, 0), + ), + ); + } - String newText = newValue.text.replaceAll(groupSeparator, ""); + String newText = valueToProcess.text.replaceAll(groupSeparator, ""); final selectionIndexFromTheRight = - newValue.text.length - newValue.selection.end; + valueToProcess.text.length - valueToProcess.selection.end; String? fraction; if (newText.contains(decimalSeparator)) { @@ -40,8 +76,9 @@ class AmountInputFormatter extends TextInputFormatter { return oldValue; } - final fractionDigits = - unit == null ? decimals : max(decimals - unit!.shift, 0); + final fractionDigits = unit == null + ? decimals + : max(decimals - unit!.shift, 0); if (newText.startsWith(decimalSeparator)) { if (newText.length - 1 > fractionDigits) { @@ -73,12 +110,7 @@ class AmountInputFormatter extends TextInputFormatter { if (val == null || val < BigInt.one) { newString = newText; } else { - // insert group separator - final regex = RegExp(r'\B(?=(\d{3})+(?!\d))'); - newString = newText.replaceAllMapped( - regex, - (m) => "${m.group(0)}${numberSymbols?.GROUP_SEP ?? ","}", - ); + newString = _groupInteger(newText, groupSeparator, grouping); } if (fraction != null) { @@ -96,3 +128,185 @@ class AmountInputFormatter extends TextInputFormatter { ); } } + +bool _hasOnlyAmountCharacters( + String value, + String decimalSeparator, + String groupSeparator, +) { + final separators = {decimalSeparator, groupSeparator, '.', ','}; + return value + .split('') + .every( + (character) => + int.tryParse(character) != null || separators.contains(character), + ); +} + +bool _isBulkEdit(String oldText, String newText) { + var prefix = 0; + while (prefix < oldText.length && + prefix < newText.length && + oldText.codeUnitAt(prefix) == newText.codeUnitAt(prefix)) { + prefix++; + } + + var oldSuffix = oldText.length; + var newSuffix = newText.length; + while (oldSuffix > prefix && + newSuffix > prefix && + oldText.codeUnitAt(oldSuffix - 1) == newText.codeUnitAt(newSuffix - 1)) { + oldSuffix--; + newSuffix--; + } + + return newSuffix - prefix > 1; +} + +String _canonicalizeSpaceGrouping(String value, String groupSeparator) { + if (!_spaceSeparators.contains(groupSeparator)) { + return value; + } + + return value.replaceAll(RegExp(r'[ \u00a0\u202f]'), groupSeparator); +} + +String? _normalizeBulkInput( + String value, { + required String decimalSeparator, + required String groupSeparator, + required _Grouping grouping, +}) { + final hasDot = value.contains('.'); + final hasComma = value.contains(','); + + if (hasDot && hasComma) { + final actualDecimal = value.lastIndexOf('.') > value.lastIndexOf(',') + ? '.' + : ','; + final group = actualDecimal == '.' ? ',' : '.'; + final decimalIndex = value.lastIndexOf(actualDecimal); + final integer = value.substring(0, decimalIndex); + final fraction = value.substring(decimalIndex + 1); + + if (fraction.contains(actualDecimal) || + fraction.contains(group) || + integer.contains(actualDecimal) || + (integer.contains(group) && + !_hasRecognizedGrouping(integer, group, grouping))) { + return null; + } + + return '${integer.replaceAll(group, '')}$decimalSeparator$fraction'; + } + + final decimalCount = decimalSeparator.allMatches(value).length; + if (decimalCount > 1) { + if (_hasRecognizedGrouping(value, decimalSeparator, grouping)) { + return value.replaceAll(decimalSeparator, ''); + } + return null; + } + + if (decimalCount == 1) { + final parts = value.split(decimalSeparator); + if (parts.length != 2 || + (parts.first.contains(groupSeparator) && + !_hasRecognizedGrouping(parts.first, groupSeparator, grouping))) { + return null; + } + return '${parts.first.replaceAll(groupSeparator, '')}' + '$decimalSeparator${parts.last}'; + } + + final groupCount = groupSeparator.allMatches(value).length; + if (groupCount > 0) { + if (_hasRecognizedGrouping(value, groupSeparator, grouping)) { + return value.replaceAll(groupSeparator, ''); + } + if (groupCount == 1 && _dotOrComma.contains(groupSeparator)) { + return value.replaceFirst(groupSeparator, decimalSeparator); + } + return null; + } + + final foreignSeparator = decimalSeparator == '.' ? ',' : '.'; + final foreignCount = foreignSeparator.allMatches(value).length; + if (foreignCount == 0) { + return value; + } + if (foreignCount == 1) { + return value.replaceFirst(foreignSeparator, decimalSeparator); + } + if (_hasRecognizedGrouping(value, foreignSeparator, grouping)) { + return value.replaceAll(foreignSeparator, ''); + } + return null; +} + +bool _hasRecognizedGrouping( + String value, + String separator, + _Grouping localeGrouping, +) => + _hasValidGrouping(value, separator, localeGrouping) || + _hasValidGrouping(value, separator, const _Grouping(3, 3)) || + _hasValidGrouping(value, separator, const _Grouping(3, 2)); + +bool _hasValidGrouping(String value, String separator, _Grouping grouping) { + final unsigned = value.startsWith(RegExp(r'[+-]')) + ? value.substring(1) + : value; + final groups = unsigned.split(separator); + if (groups.length < 2 || + groups.any((part) => !RegExp(r'^\d+$').hasMatch(part))) { + return false; + } + if (groups.last.length != grouping.primary) { + return false; + } + for (var i = groups.length - 2; i > 0; i--) { + if (groups[i].length != grouping.secondary) { + return false; + } + } + return groups.first.isNotEmpty && groups.first.length <= grouping.secondary; +} + +String _groupInteger(String value, String separator, _Grouping grouping) { + final sign = value.startsWith(RegExp(r'[+-]')) ? value[0] : ''; + var digits = sign.isEmpty ? value : value.substring(1); + if (!RegExp(r'^\d+$').hasMatch(digits) || digits.length <= grouping.primary) { + return value; + } + + final groups = []; + var size = grouping.primary; + while (digits.length > size) { + groups.add(digits.substring(digits.length - size)); + digits = digits.substring(0, digits.length - size); + size = grouping.secondary; + } + groups.add(digits); + return '$sign${groups.reversed.join(separator)}'; +} + +class _Grouping { + const _Grouping(this.primary, this.secondary); + + factory _Grouping.fromPattern(String pattern) { + final integerPattern = pattern.split('.').first; + final groups = integerPattern.split(','); + final primary = groups.length > 1 ? groups.last.length : 3; + final secondary = groups.length > 2 + ? groups[groups.length - 2].length + : primary; + return _Grouping(primary, secondary); + } + + final int primary; + final int secondary; +} + +const _dotOrComma = {'.', ','}; +const _spaceSeparators = {' ', '\u00a0', '\u202f'}; diff --git a/lib/utilities/util.dart b/lib/utilities/util.dart index c722832ef2..04d684175d 100644 --- a/lib/utilities/util.dart +++ b/lib/utilities/util.dart @@ -32,11 +32,12 @@ abstract class Util { static double? screenWidth; static NumberSymbols? getSymbolsFor({required String locale}) { + final normalized = locale.replaceAll("-", "_"); + final language = normalized.split("_").first.toLowerCase(); + return numberFormatSymbols[locale] as NumberSymbols? ?? - numberFormatSymbols[locale.replaceAll("-", "_")] as NumberSymbols? ?? - numberFormatSymbols[locale.substring(3).toLowerCase()] - as NumberSymbols? ?? - numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?; + numberFormatSymbols[normalized] as NumberSymbols? ?? + numberFormatSymbols[language] as NumberSymbols?; } static bool get isDesktop { diff --git a/lib/widgets/eth_fee_form.dart b/lib/widgets/eth_fee_form.dart index 2f4e2889ee..0d5e8b82ab 100644 --- a/lib/widgets/eth_fee_form.dart +++ b/lib/widgets/eth_fee_form.dart @@ -2,9 +2,12 @@ import 'dart:async'; import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/global/locale_provider.dart'; import '../services/ethereum/ethereum_api.dart'; import '../themes/stack_colors.dart'; +import '../utilities/amount/amount_input_formatter.dart'; import '../utilities/constants.dart'; import '../utilities/text_styles.dart'; import '../utilities/util.dart'; @@ -35,7 +38,7 @@ class EthEIP1559Fee { "gasLimit: $gasLimit)"; } -class EthFeeForm extends StatefulWidget { +class EthFeeForm extends ConsumerStatefulWidget { EthFeeForm({ super.key, this.minGasLimit = 21000, @@ -56,10 +59,10 @@ class EthFeeForm extends StatefulWidget { final void Function(EthEIP1559Fee) stateChanged; @override - State createState() => _EthFeeFormState(); + ConsumerState createState() => _EthFeeFormState(); } -class _EthFeeFormState extends State { +class _EthFeeFormState extends ConsumerState { static const _textFadeDuration = Duration(milliseconds: 300); final maxBaseController = TextEditingController(); @@ -71,11 +74,26 @@ class _EthFeeFormState extends State { late int _gasLimitCache; + // Decimal and int expect ungrouped input with a "." decimal separator. + String _normalizeForParsing(String value) { + final locale = ref.read(localeServiceChangeNotifierProvider).locale; + final numberSymbols = Util.getSymbolsFor(locale: locale); + final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; + final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; + + return value + .replaceAll(groupSeparator, "") + .replaceFirst(decimalSeparator, "."); + } + EthEIP1559Fee get _current => EthEIP1559Fee( - maxBaseFeeGwei: Decimal.tryParse(maxBaseController.text) ?? Decimal.zero, + maxBaseFeeGwei: + Decimal.tryParse(_normalizeForParsing(maxBaseController.text)) ?? + Decimal.zero, priorityFeeGwei: - Decimal.tryParse(priorityFeeController.text) ?? Decimal.zero, - gasLimit: int.parse(gasLimitController.text), + Decimal.tryParse(_normalizeForParsing(priorityFeeController.text)) ?? + Decimal.zero, + gasLimit: int.parse(_normalizeForParsing(gasLimitController.text)), ); String _currentBase = "Current: "; @@ -107,10 +125,21 @@ class _EthFeeFormState extends State { ); }); + final locale = ref.read(localeServiceChangeNotifierProvider).locale; + final decimalSeparator = + Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? "."; maxBaseController.text = - widget.initialState?.maxBaseFeeGwei.toString() ?? ""; + widget.initialState?.maxBaseFeeGwei.toString().replaceFirst( + ".", + decimalSeparator, + ) ?? + ""; priorityFeeController.text = - widget.initialState?.priorityFeeGwei.toString() ?? ""; + widget.initialState?.priorityFeeGwei.toString().replaceFirst( + ".", + decimalSeparator, + ) ?? + ""; _gasLimitCache = widget.initialState?.gasLimit ?? widget.minGasLimit; gasLimitController.text = _gasLimitCache.toString(); @@ -132,6 +161,10 @@ class _EthFeeFormState extends State { @override Widget build(BuildContext context) { + final locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -149,41 +182,42 @@ class _EthFeeFormState extends State { autocorrect: false, enableSuggestions: false, keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + AmountInputFormatter(decimals: 9, locale: locale), + ], focusNode: maxBaseFocus, onChanged: (value) { widget.stateChanged(_current); }, - style: - Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - null, - maxBaseFocus, - context, - desktopMed: Util.isDesktop, - ).copyWith( - contentPadding: EdgeInsets.only( - left: 16, - top: Util.isDesktop ? 11 : 6, - bottom: Util.isDesktop ? 12 : 8, - right: 5, - ), - ), + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + null, + maxBaseFocus, + context, + desktopMed: Util.isDesktop, + ).copyWith( + contentPadding: EdgeInsets.only( + left: 16, + top: Util.isDesktop ? 11 : 6, + bottom: Util.isDesktop ? 12 : 8, + right: 5, + ), + ), ), ), const SizedBox(height: 6), AnimatedSwitcher( duration: _textFadeDuration, - transitionBuilder: - (child, animation) => - FadeTransition(opacity: animation, child: child), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), child: Text( _currentBase, key: ValueKey( @@ -207,41 +241,42 @@ class _EthFeeFormState extends State { autocorrect: false, enableSuggestions: false, keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + AmountInputFormatter(decimals: 9, locale: locale), + ], focusNode: priorityFeeFocus, onChanged: (value) { widget.stateChanged(_current); }, - style: - Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - null, - priorityFeeFocus, - context, - desktopMed: Util.isDesktop, - ).copyWith( - contentPadding: EdgeInsets.only( - left: 16, - top: Util.isDesktop ? 11 : 6, - bottom: Util.isDesktop ? 12 : 8, - right: 5, - ), - ), + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + null, + priorityFeeFocus, + context, + desktopMed: Util.isDesktop, + ).copyWith( + contentPadding: EdgeInsets.only( + left: 16, + top: Util.isDesktop ? 11 : 6, + bottom: Util.isDesktop ? 12 : 8, + right: 5, + ), + ), ), ), const SizedBox(height: 6), AnimatedSwitcher( duration: _textFadeDuration, - transitionBuilder: - (child, animation) => - FadeTransition(opacity: animation, child: child), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), child: Text( _currentPriority, key: ValueKey( @@ -264,10 +299,13 @@ class _EthFeeFormState extends State { readOnly: false, autocorrect: false, enableSuggestions: false, - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions(), + inputFormatters: [ + AmountInputFormatter(decimals: 0, locale: locale), + ], focusNode: gasLimitFocus, onChanged: (value) { - final intValue = int.tryParse(value); + final intValue = int.tryParse(_normalizeForParsing(value)); if (intValue == null || intValue < widget.minGasLimit || intValue > widget.maxGasLimit) { @@ -279,29 +317,28 @@ class _EthFeeFormState extends State { widget.stateChanged(_current); }, - style: - Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - null, - gasLimitFocus, - context, - desktopMed: Util.isDesktop, - ).copyWith( - contentPadding: EdgeInsets.only( - left: 16, - top: Util.isDesktop ? 11 : 6, - bottom: Util.isDesktop ? 12 : 8, - right: 5, - ), - ), + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + null, + gasLimitFocus, + context, + desktopMed: Util.isDesktop, + ).copyWith( + contentPadding: EdgeInsets.only( + left: 16, + top: Util.isDesktop ? 11 : 6, + bottom: Util.isDesktop ? 12 : 8, + right: 5, + ), + ), ), ), ], diff --git a/test/pages/buy_view/buy_form_test.dart b/test/pages/buy_view/buy_form_test.dart new file mode 100644 index 0000000000..3046ef3387 --- /dev/null +++ b/test/pages/buy_view/buy_form_test.dart @@ -0,0 +1,176 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/networking/http.dart'; +import 'package:stackwallet/pages/buy_view/buy_form.dart'; +import 'package:stackwallet/services/buy/simplex/simplex_api.dart'; +import 'package:stackwallet/themes/coin_icon_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; + +import '../../sample_data/theme_json.dart'; + +/// Captures the Simplex quote request instead of hitting the network. +class _CapturingHttp extends HTTP { + const _CapturingHttp(); + + static Uri? lastGet; + + @override + Future get({ + required Uri url, + Map? headers, + required ({InternetAddress host, int port})? proxyInfo, + Duration? connectionTimeout, + }) async { + lastGet = url; + return Response(utf8.encode('{"error":"stubbed"}'), 200); + } +} + +Widget _app(Widget child) => ProviderScope( + overrides: [ + coinIconProvider.overrideWithProvider( + (coin) => Provider((ref) => "assets/svg/bell.svg"), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Material(child: SingleChildScrollView(child: child)), + ), +); + +Finder get _amountField => + find.byKey(const Key("buyAmountInputFieldTextFieldKey")); + +/// The fiat/crypto mode is static state on the form, so put it where the test +/// wants it rather than assuming the default. +Future _useCryptoAmount(WidgetTester tester) async { + final toggle = find.byWidgetPredicate( + (w) => w is CustomTextButton && w.text == "Use crypto amount", + ); + if (toggle.evaluate().isNotEmpty) { + await tester.tap(toggle, warnIfMissed: false); + await tester.pump(); + } + expect(find.text("Enter crypto amount"), findsOneWidget); +} + +void main() { + late Directory hiveDir; + + setUp(() async { + // Prefs reads the prefs box through DB's own Hive instance; the Simplex + // quote request needs it open. + hiveDir = await Directory.systemTemp.createTemp("buy_form_test"); + DB.instance.hive.init(hiveDir.path); + await DB.instance.hive.openBox(DB.boxNamePrefs); + await Prefs.instance.init(); + }); + + tearDown(() async { + await DB.instance.hive.close(); + await hiveDir.delete(recursive: true); + }); + + testWidgets("desktop buy form builds its amount field without a coin", ( + tester, + ) async { + tester.view.physicalSize = const Size(1600, 3000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(_app(const BuyForm())); + + expect( + "${tester.takeException()}", + isNot(contains("LateInitializationError")), + ); + expect(_amountField, findsOneWidget); + }); + + testWidgets("mobile buy form builds its amount field with a coin", ( + tester, + ) async { + tester.view.physicalSize = const Size(1600, 3000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + _app(BuyForm(coin: Bitcoin(CryptoCurrencyNetwork.main))), + ); + + expect( + "${tester.takeException()}", + isNot(contains("LateInitializationError")), + ); + expect(_amountField, findsOneWidget); + }); + + testWidgets("crypto amount reaches the quote request at full precision", ( + tester, + ) async { + SimplexAPI.instance.client = const _CapturingHttp(); + _CapturingHttp.lastGet = null; + + tester.view.physicalSize = const Size(1600, 3000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + // Desktop builds the form with no coin; the amount is denominated in the + // selected crypto, not the wallet's. + await tester.pumpWidget(_app(const BuyForm())); + tester.takeException(); + await _useCryptoAmount(tester); + + await tester.enterText(_amountField, "1.23456789"); + await tester.pump(); + expect( + (tester.widget(_amountField) as TextField).controller!.text, + "1.23456789", + ); + expect(find.text("Invalid amount"), findsNothing); + + await tester.enterText( + find.byKey(const Key("buyViewReceiveAddressFieldKey")), + "bc1qexampleaddress", + ); + await tester.pump(); + + await tester.tap(find.byType(PrimaryButton)); + await tester.pump(); + await tester.pump(); + tester.takeException(); + + expect(_CapturingHttp.lastGet, isNotNull); + expect( + _CapturingHttp.lastGet!.queryParameters["REQUESTED_AMOUNT"], + "1.23456789", + ); + expect(_CapturingHttp.lastGet!.queryParameters["CRYPTO_TICKER"], "BTC"); + }); +} diff --git a/test/utilities/amount/amount_input_formatter_test.dart b/test/utilities/amount/amount_input_formatter_test.dart new file mode 100644 index 0000000000..309f3a521b --- /dev/null +++ b/test/utilities/amount/amount_input_formatter_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; + +void main() { + group('AmountInputFormatter bulk input', () { + const cases = <({String locale, String input, String expected})>[ + (locale: 'en_US', input: '1,234', expected: '1,234'), + (locale: 'de_DE', input: '1.234', expected: '1.234'), + (locale: 'en_US', input: '1,23', expected: '1.23'), + (locale: 'de_DE', input: '1.23', expected: '1,23'), + (locale: 'en_US', input: '1.234,56', expected: '1,234.56'), + (locale: 'de_DE', input: '1,234.56', expected: '1.234,56'), + (locale: 'fr_FR', input: '1\u202f234,56', expected: '1\u202f234,56'), + (locale: 'fr_FR', input: '1\u00a0234,56', expected: '1\u202f234,56'), + (locale: 'fr_FR', input: '1 234,56', expected: '1\u202f234,56'), + (locale: 'hi_IN', input: '12,34,567.89', expected: '12,34,567.89'), + (locale: 'hi_IN', input: '1,234,567.89', expected: '12,34,567.89'), + (locale: 'en_US', input: '0,00', expected: '0.00'), + ]; + + for (final testCase in cases) { + test('${testCase.locale}: ${testCase.input}', () { + final result = _format( + locale: testCase.locale, + newValue: _value(testCase.input), + ); + + expect(result.text, testCase.expected); + expect( + result.selection, + TextSelection.collapsed(offset: result.text.length), + ); + }); + } + + test('detects a same-length selected-text replacement', () { + final result = _format( + locale: 'en_US', + oldValue: const TextEditingValue( + text: '12.34', + selection: TextSelection(baseOffset: 0, extentOffset: 5), + ), + newValue: _value('1,23'), + ); + + expect(result.text, '1.23'); + }); + + test('applies the configured fractional precision', () { + expect( + _format(locale: 'de_DE', decimals: 2, newValue: _value('1,2345')).text, + '1,23', + ); + }); + + test('applies crypto unit and token precision', () { + expect( + _format( + locale: 'de_DE', + decimals: 8, + unit: AmountUnit.milli, + newValue: _value('10,123456'), + ).text, + '10,12345', + ); + expect( + _format( + locale: 'en_US', + decimals: 6, + newValue: _value('0.12345678'), + ).text, + '0.123456', + ); + }); + + test('rejects signed amounts', () { + expect(_format(locale: 'en_US', newValue: _value('-1,23')).text, isEmpty); + expect(_format(locale: 'en_US', newValue: _value('+1.23')).text, isEmpty); + }); + + test('rejects non-numeric paste and typing', () { + for (final input in ['1e3', r'$1.23', 'abc']) { + expect(_format(locale: 'en_US', newValue: _value(input)).text, isEmpty); + } + expect(_format(locale: 'en_US', newValue: _value('a')).text, isEmpty); + }); + + test('keeps a grouped integer when no fractions are allowed', () { + expect( + _format(locale: 'en_US', decimals: 0, newValue: _value('21,000')).text, + '21,000', + ); + expect( + _format(locale: 'de_DE', decimals: 0, newValue: _value('21.000')).text, + '21.000', + ); + }); + }); + + test('preserves the cursor during a single-character insertion', () { + final result = _format( + locale: 'en_US', + oldValue: const TextEditingValue( + text: '12.34', + selection: TextSelection.collapsed(offset: 2), + ), + newValue: const TextEditingValue( + text: '120.34', + selection: TextSelection.collapsed(offset: 3), + ), + ); + + expect(result.text, '120.34'); + expect(result.selection, const TextSelection.collapsed(offset: 3)); + }); +} + +TextEditingValue _format({ + required String locale, + required TextEditingValue newValue, + TextEditingValue oldValue = TextEditingValue.empty, + int decimals = 8, + AmountUnit? unit, +}) => AmountInputFormatter( + decimals: decimals, + locale: locale, + unit: unit, +).formatEditUpdate(oldValue, newValue); + +TextEditingValue _value(String text) => TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), +);