From 1ce9aec1147f5a6f9df8578b6bf8862bd15b5057 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Aug 2026 12:46:31 -0500 Subject: [PATCH 1/4] style: format the amount input formatter and eth fee form Both files predate the current dart formatter, which fails CI's check of the files a change touches. --- .../amount/amount_input_formatter.dart | 5 +- lib/widgets/eth_fee_form.dart | 145 +++++++++--------- 2 files changed, 73 insertions(+), 77 deletions(-) diff --git a/lib/utilities/amount/amount_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart index 2ecd9fe540..ada8e15f32 100644 --- a/lib/utilities/amount/amount_input_formatter.dart +++ b/lib/utilities/amount/amount_input_formatter.dart @@ -40,8 +40,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) { diff --git a/lib/widgets/eth_fee_form.dart b/lib/widgets/eth_fee_form.dart index 2f4e2889ee..814157c6ef 100644 --- a/lib/widgets/eth_fee_form.dart +++ b/lib/widgets/eth_fee_form.dart @@ -153,37 +153,35 @@ class _EthFeeFormState extends State { 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( @@ -211,37 +209,35 @@ class _EthFeeFormState extends State { 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( @@ -279,29 +275,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, + ), + ), ), ), ], From ba5db05872ccd41a3a4db6930fd2267a279e20b4 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Aug 2026 12:46:31 -0500 Subject: [PATCH 2/4] fix(amounts): group and parse amount input using the device locale Amount input grouped every three digits and, on a paste, simply dropped the locale's group separator, so an amount copied from a differently formatted locale silently changed value: pasting de_DE's 1.234,56 into an en_US field left 1.23456. Group with the locale's own pattern, including the 3/2 Indian one, keep its decimal separator, canonicalize the space separators, and resolve a bulk edit's foreign separators deterministically - local grouping wins where the digits form valid groups, otherwise a lone foreign separator reads as the decimal point. Anything else is rejected outright. Util.getSymbolsFor's last fallback took the region out of a locale string, so every *_IN locale resolved to Indonesian symbols and an unknown two letter locale threw RangeError. --- .../amount/amount_input_formatter.dart | 229 +++++++++++++++++- lib/utilities/util.dart | 9 +- .../amount/amount_input_formatter_test.dart | 135 +++++++++++ 3 files changed, 361 insertions(+), 12 deletions(-) create mode 100644 test/utilities/amount/amount_input_formatter_test.dart diff --git a/lib/utilities/amount/amount_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart index ada8e15f32..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)) { @@ -74,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) { @@ -97,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/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), +); From 4c698fc0bad44214878754805d43f7dd977a9ea9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Aug 2026 12:46:31 -0500 Subject: [PATCH 3/4] fix(send): read fee sheet amounts with the locale aware parser The three transaction fee selection sheets parsed their amount with Decimal.tryParse, which cannot read the grouped text the amount field holds, so the sheet fell back to zero for any amount long enough to be grouped and sized its fee estimates against nothing. --- lib/pages/send_view/send_view.dart | 9 +++--- lib/pages/send_view/sol_token_send_view.dart | 33 ++++++++++++++------ lib/pages/send_view/token_send_view.dart | 24 ++++++++------ 3 files changed, 43 insertions(+), 23 deletions(-) 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) { From ba77c49489a6cc5cb689a297811795da1d1f07cb Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 25 Aug 2026 12:46:31 -0500 Subject: [PATCH 4/4] fix(buy): localize the buy amount and eth fee amount fields Neither field had an amount input formatter and both parsed raw Decimal text, so a grouped or comma decimal amount was rejected or misread, and a buy quote threw on Decimal.parse instead of reporting an invalid amount. The buy amount is denominated in the crypto being bought, which need not be the wallet's coin and is absent entirely on desktop, so resolve the currency from that selection; with none, fail closed rather than reading a crypto amount with the two decimal fiat parser. --- lib/pages/buy_view/buy_form.dart | 80 +++++++++-- lib/widgets/eth_fee_form.dart | 62 +++++++-- test/pages/buy_view/buy_form_test.dart | 176 +++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 23 deletions(-) create mode 100644 test/pages/buy_view/buy_form_test.dart 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/widgets/eth_fee_form.dart b/lib/widgets/eth_fee_form.dart index 814157c6ef..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,6 +182,9 @@ 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); @@ -205,6 +241,9 @@ 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); @@ -260,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) { 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"); + }); +}