diff --git a/lib/main.dart b/lib/main.dart index 852ffc9c0..b39f9db64 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -463,6 +463,12 @@ class _MaterialAppWithThemeState extends ConsumerState super.dispose(); } + @override + void didChangeLocales(List? locales) { + ref.read(localeServiceChangeNotifierProvider).loadLocale(); + super.didChangeLocales(locales); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) async { debugPrint("didChangeAppLifecycleState: ${state.name}"); diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index eb0e47056..0f503588c 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -10,7 +10,6 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math'; import 'package:bip47/bip47.dart'; import 'package:cw_core/monero_transaction_priority.dart'; @@ -41,6 +40,7 @@ import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/address_utils.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; @@ -128,27 +128,11 @@ class _SendViewState extends ConsumerState { void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { - String cryptoAmount = cryptoAmountController.text; - if (cryptoAmount.isNotEmpty && - cryptoAmount != "." && - cryptoAmount != ",") { - if (cryptoAmount.startsWith("~")) { - cryptoAmount = cryptoAmount.substring(1); - } - if (cryptoAmount.contains(" ")) { - cryptoAmount = cryptoAmount.split(" ").first; - } - - // ensure we don't shift past minimum atomic value - final shift = min(ref.read(pAmountUnit(coin)).shift, coin.decimals); - - _amountToSend = cryptoAmount.contains(",") - ? Decimal.parse(cryptoAmount.replaceFirst(",", ".")) - .shift(0 - shift) - .toAmount(fractionDigits: coin.decimals) - : Decimal.parse(cryptoAmount) - .shift(0 - shift) - .toAmount(fractionDigits: coin.decimals); + final cryptoAmount = ref.read(pAmountFormatter(coin)).tryParse( + cryptoAmountController.text, + ); + if (cryptoAmount != null) { + _amountToSend = cryptoAmount; if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { return; @@ -1567,13 +1551,21 @@ class _SendViewState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ + AmountInputFormatter( + decimals: coin.decimals, + unit: ref.watch(pAmountUnit(coin)), + locale: locale, + ), + // regex to validate a crypto amount with 8 decimal places - TextInputFormatter.withFunction((oldValue, - newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + // TextInputFormatter.withFunction((oldValue, + // newValue) => + // // RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') + // // RegExp(r'^\d{1,3}([,\.]\d+)?|[,\.\d]+$') + // getAmountRegex(locale, coin.decimals) + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], decoration: InputDecoration( contentPadding: const EdgeInsets.only( @@ -1628,26 +1620,25 @@ class _SendViewState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ + AmountInputFormatter( + decimals: 2, + locale: locale, + ), // regex to validate a fiat amount with 2 decimal places - TextInputFormatter.withFunction((oldValue, - newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + // TextInputFormatter.withFunction((oldValue, + // newValue) => + // // RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') + // getAmountRegex(locale, 2) + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], onChanged: (baseAmountString) { - if (baseAmountString.isNotEmpty && - baseAmountString != "." && - baseAmountString != ",") { - final Amount baseAmount = - baseAmountString.contains(",") - ? Decimal.parse(baseAmountString - .replaceFirst(",", ".")) - .toAmount(fractionDigits: 2) - : Decimal.parse(baseAmountString) - .toAmount(fractionDigits: 2); - + final baseAmount = Amount.tryParseFiatString( + baseAmountString, + locale: locale, + ); + if (baseAmount != null) { final Decimal _price = ref .read(priceAnd24hChangeNotifierProvider) .getPrice(coin) diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 1a850f517..8d8e6537c 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -31,6 +31,7 @@ import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/address_utils.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; import 'package:stackwallet/utilities/clipboard_interface.dart'; @@ -218,16 +219,11 @@ class _TokenSendViewState extends ConsumerState { } void _onFiatAmountFieldChanged(String baseAmountString) { - if (baseAmountString.isNotEmpty && - baseAmountString != "." && - baseAmountString != ",") { - final baseAmount = Amount.fromDecimal( - baseAmountString.contains(",") - ? Decimal.parse(baseAmountString.replaceFirst(",", ".")) - : Decimal.parse(baseAmountString), - fractionDigits: tokenContract.decimals, - ); - + final baseAmount = Amount.tryParseFiatString( + baseAmountString, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + if (baseAmount != null) { final _price = ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice(tokenContract.address) @@ -272,22 +268,12 @@ class _TokenSendViewState extends ConsumerState { void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { - String cryptoAmount = cryptoAmountController.text; - if (cryptoAmount.isNotEmpty && - cryptoAmount != "." && - cryptoAmount != ",") { - if (cryptoAmount.startsWith("~")) { - cryptoAmount = cryptoAmount.substring(1); - } - if (cryptoAmount.contains(" ")) { - cryptoAmount = cryptoAmount.split(" ").first; - } - - _amountToSend = Amount.fromDecimal( - cryptoAmount.contains(",") - ? Decimal.parse(cryptoAmount.replaceFirst(",", ".")) - : Decimal.parse(cryptoAmount), - fractionDigits: tokenContract.decimals); + final cryptoAmount = ref.read(pAmountFormatter(coin)).tryParse( + cryptoAmountController.text, + ethContract: tokenContract, + ); + if (cryptoAmount != null) { + _amountToSend = cryptoAmount; if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { return; @@ -952,13 +938,18 @@ class _TokenSendViewState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ - // regex to validate a crypto amount with 8 decimal places - TextInputFormatter.withFunction((oldValue, - newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + AmountInputFormatter( + decimals: tokenContract.decimals, + unit: ref.watch(pAmountUnit(coin)), + locale: locale, + ), + // // regex to validate a crypto amount with 8 decimal places + // TextInputFormatter.withFunction((oldValue, + // newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], decoration: InputDecoration( contentPadding: const EdgeInsets.only( @@ -1011,13 +1002,17 @@ class _TokenSendViewState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ - // regex to validate a fiat amount with 2 decimal places - TextInputFormatter.withFunction((oldValue, - newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + AmountInputFormatter( + decimals: 2, + locale: locale, + ), + // // regex to validate a fiat amount with 2 decimal places + // TextInputFormatter.withFunction((oldValue, + // newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], onChanged: _onFiatAmountFieldChanged, decoration: InputDecoration( @@ -1187,7 +1182,7 @@ class _TokenSendViewState extends ConsumerState { ConnectionState.done && snapshot.hasData) { return Text( - "~${snapshot.data! as String}", + "~${snapshot.data!}", style: STextStyles.itemSubtitle( context), diff --git a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart index b41177ab9..1a1513c1d 100644 --- a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart +++ b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart @@ -10,16 +10,17 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_rounded_date_picker/flutter_rounded_date_picker.dart'; import 'package:flutter_svg/svg.dart'; import 'package:stackwallet/models/transaction_filter.dart'; +import 'package:stackwallet/providers/global/locale_provider.dart'; import 'package:stackwallet/providers/ui/transaction_filter_provider.dart'; import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/themes/theme_providers.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; @@ -757,12 +758,20 @@ class _TransactionSearchViewState decimal: true, ), inputFormatters: [ - // regex to validate a crypto amount with 8 decimal places - TextInputFormatter.withFunction((oldValue, newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + AmountInputFormatter( + decimals: widget.coin.decimals, + unit: ref.watch(pAmountUnit(widget.coin)), + locale: ref.watch( + localeServiceChangeNotifierProvider + .select((value) => value.locale), + ), + ), + // // regex to validate a crypto amount with 8 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], style: isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 748166169..76ac3fef9 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -40,6 +40,7 @@ import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/address_utils.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; @@ -1054,12 +1055,17 @@ class _DesktopSendState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ - // regex to validate a crypto amount with 8 decimal places - TextInputFormatter.withFunction((oldValue, newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + AmountInputFormatter( + decimals: coin.decimals, + unit: ref.watch(pAmountUnit(coin)), + locale: locale, + ), + // // regex to validate a crypto amount with 8 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], onChanged: (newValue) {}, decoration: InputDecoration( @@ -1111,12 +1117,16 @@ class _DesktopSendState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ - // regex to validate a fiat amount with 2 decimal places - TextInputFormatter.withFunction((oldValue, newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + AmountInputFormatter( + decimals: 2, + locale: locale, + ), + // // regex to validate a fiat amount with 2 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], onChanged: fiatTextFieldOnChanged, decoration: InputDecoration( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart index 6ce7700bc..ef9b0d9d4 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart @@ -31,6 +31,7 @@ import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/address_utils.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; import 'package:stackwallet/utilities/clipboard_interface.dart'; import 'package:stackwallet/utilities/constants.dart'; @@ -50,7 +51,7 @@ import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; import 'package:stackwallet/widgets/stack_text_field.dart'; import 'package:stackwallet/widgets/textfield_icon_button.dart'; -const _kCryptoAmountRegex = r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$'; +// const _kCryptoAmountRegex = r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$'; class DesktopTokenSend extends ConsumerStatefulWidget { const DesktopTokenSend({ @@ -717,15 +718,23 @@ class _DesktopTokenSendState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ + AmountInputFormatter( + decimals: tokenContract.decimals, + unit: ref.watch(pAmountUnit(coin)), + locale: ref.watch( + localeServiceChangeNotifierProvider + .select((value) => value.locale), + ), + ), // regex to validate a crypto amount with 8 decimal places - TextInputFormatter.withFunction((oldValue, newValue) => RegExp( - _kCryptoAmountRegex.replaceAll( - "0,8", - "0,${tokenContract.decimals}", - ), - ).hasMatch(newValue.text) - ? newValue - : oldValue), + // TextInputFormatter.withFunction((oldValue, newValue) => RegExp( + // _kCryptoAmountRegex.replaceAll( + // "0,8", + // "0,${tokenContract.decimals}", + // ), + // ).hasMatch(newValue.text) + // ? newValue + // : oldValue), ], onChanged: (newValue) {}, decoration: InputDecoration( @@ -777,12 +786,19 @@ class _DesktopTokenSendState extends ConsumerState { ), textAlign: TextAlign.right, inputFormatters: [ - // regex to validate a fiat amount with 2 decimal places - TextInputFormatter.withFunction((oldValue, newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + AmountInputFormatter( + decimals: 2, + locale: ref.watch( + localeServiceChangeNotifierProvider + .select((value) => value.locale), + ), + ), + // // regex to validate a fiat amount with 2 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], onChanged: fiatTextFieldOnChanged, decoration: InputDecoration( diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart index e0225d34f..0e8790064 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -32,6 +32,39 @@ class Amount { : assert(fractionDigits >= 0), _value = amount.shift(fractionDigits).toBigInt(); + static Amount? tryParseFiatString( + String value, { + required String locale, + }) { + final parts = value.split(" "); + + if (parts.first.isEmpty) { + return null; + } + + String str = parts.first; + if (str.startsWith(RegExp(r'[+-]'))) { + str = str.substring(1); + } + + if (str.isEmpty) { + return null; + } + + // get number symbols for decimal place and group separator + final numberSymbols = numberFormatSymbols[locale] as NumberSymbols? ?? + numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?; + + final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; + final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; + + str = str.replaceAll(groupSeparator, ""); + + final decimalString = str.replaceFirst(decimalSeparator, "."); + + return Decimal.tryParse(decimalString)?.toAmount(fractionDigits: 2); + } + // =========================================================================== // ======= Instance properties =============================================== @@ -67,15 +100,23 @@ class Amount { }) { final wholeNumber = decimal.truncate(); - final String separator = - (numberFormatSymbols[locale] as NumberSymbols?)?.DECIMAL_SEP ?? - (numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?) - ?.DECIMAL_SEP ?? - "."; + // get number symbols for decimal place and group separator + final numberSymbols = numberFormatSymbols[locale] as NumberSymbols? ?? + numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?; + + final String separator = numberSymbols?.DECIMAL_SEP ?? "."; final fraction = decimal - wholeNumber; - return "${wholeNumber.toStringAsFixed(0)}$separator${fraction.toStringAsFixed(2).substring(2)}"; + String wholeNumberString = wholeNumber.toStringAsFixed(0); + // insert group separator + final regex = RegExp(r'\B(?=(\d{3})+(?!\d))'); + wholeNumberString = wholeNumberString.replaceAllMapped( + regex, + (m) => "${m.group(0)}${numberSymbols?.GROUP_SEP ?? ","}", + ); + + return "$wholeNumberString$separator${fraction.toStringAsFixed(2).substring(2)}"; } // String localizedStringAsFixed({ // required String locale, diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart index 3e556c95f..7c5a8f29b 100644 --- a/lib/utilities/amount/amount_formatter.dart +++ b/lib/utilities/amount/amount_formatter.dart @@ -63,4 +63,11 @@ class AmountFormatter { tokenContract: ethContract, ); } + + Amount? tryParse( + String string, { + EthContract? ethContract, + }) { + return unit.tryParse(string, locale: locale, coin: coin); + } } diff --git a/lib/utilities/amount/amount_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart new file mode 100644 index 000000000..c3157c6fa --- /dev/null +++ b/lib/utilities/amount/amount_input_formatter.dart @@ -0,0 +1,93 @@ +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:intl/number_symbols.dart'; +import 'package:intl/number_symbols_data.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; + +class AmountInputFormatter extends TextInputFormatter { + final int decimals; + final String locale; + final AmountUnit? unit; + + AmountInputFormatter({ + required this.decimals, + required this.locale, + this.unit, + }); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + // get number symbols for decimal place and group separator + final numberSymbols = numberFormatSymbols[locale] as NumberSymbols? ?? + numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?; + + final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; + final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; + + String newText = newValue.text.replaceAll(groupSeparator, ""); + + final selectionIndexFromTheRight = + newValue.text.length - newValue.selection.end; + + String? fraction; + if (newText.contains(decimalSeparator)) { + final parts = newText.split(decimalSeparator); + + if (parts.length > 2) { + return oldValue; + } + if (newText.startsWith(decimalSeparator)) { + return TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: newText.length - selectionIndexFromTheRight, + ), + ); + } + + newText = parts.first; + if (parts.length == 2) { + fraction = parts.last; + } else { + fraction = ""; + } + + final fractionDigits = + unit == null ? decimals : max(decimals - unit!.shift, 0); + + if (fraction.length > fractionDigits) { + return oldValue; + } + } + + if (newText.trim() == '' || newText.trim() == '0') { + return newValue.copyWith(text: ''); + } else if (BigInt.parse(newText) < BigInt.one) { + return newValue.copyWith(text: ''); + } + + // insert group separator + final regex = RegExp(r'\B(?=(\d{3})+(?!\d))'); + + String newString = newText.replaceAllMapped( + regex, + (m) => "${m.group(0)}${numberSymbols?.GROUP_SEP ?? ","}", + ); + + if (fraction != null) { + newString += decimalSeparator; + if (fraction.isNotEmpty) { + newString += fraction; + } + } + + return TextEditingValue( + text: newString, + selection: TextSelection.collapsed( + offset: newString.length - selectionIndexFromTheRight, + ), + ); + } +} diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index ee54f6d1a..79b36130f 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -164,6 +164,51 @@ extension AmountUnitExt on AmountUnit { } } + Amount? tryParse( + String value, { + required String locale, + required Coin coin, + EthContract? tokenContract, + }) { + final precisionLost = value.startsWith("~"); + + final parts = (precisionLost ? value.substring(1) : value).split(" "); + + if (parts.first.isEmpty) { + return null; + } + + String str = parts.first; + if (str.startsWith(RegExp(r'[+-]'))) { + str = str.substring(1); + } + + if (str.isEmpty) { + return null; + } + + // get number symbols for decimal place and group separator + final numberSymbols = numberFormatSymbols[locale] as NumberSymbols? ?? + numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?; + + final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; + final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; + + str = str.replaceAll(groupSeparator, ""); + + final decimalString = str.replaceFirst(decimalSeparator, "."); + final Decimal? decimal = Decimal.tryParse(decimalString); + + if (decimal == null) { + return null; + } + + final decimalPlaces = tokenContract?.decimals ?? coin.decimals; + final realShift = math.min(shift, decimalPlaces); + + return decimal.shift(0 - realShift).toAmount(fractionDigits: decimalPlaces); + } + String displayAmount({ required Amount amount, required String locale, @@ -191,6 +236,17 @@ extension AmountUnitExt on AmountUnit { // start building the return value with just the whole value String returnValue = wholeNumber.toString(); + // get number symbols for decimal place and group separator + final numberSymbols = numberFormatSymbols[locale] as NumberSymbols? ?? + numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?; + + // insert group separator + final regex = RegExp(r'\B(?=(\d{3})+(?!\d))'); + returnValue = returnValue.replaceAllMapped( + regex, + (m) => "${m.group(0)}${numberSymbols?.GROUP_SEP ?? ","}", + ); + // if true and withUnitName is true, we will show "~" prepended on amount bool didLosePrecision = false; @@ -239,11 +295,7 @@ extension AmountUnitExt on AmountUnit { } // get decimal separator based on locale - final String separator = - (numberFormatSymbols[locale] as NumberSymbols?)?.DECIMAL_SEP ?? - (numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?) - ?.DECIMAL_SEP ?? - "."; + final String separator = numberSymbols?.DECIMAL_SEP ?? "."; // append separator and fractional amount returnValue += "$separator$remainder"; diff --git a/lib/widgets/textfields/exchange_textfield.dart b/lib/widgets/textfields/exchange_textfield.dart index f981f023a..4abcb6671 100644 --- a/lib/widgets/textfields/exchange_textfield.dart +++ b/lib/widgets/textfields/exchange_textfield.dart @@ -9,17 +9,19 @@ */ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:stackwallet/models/exchange/aggregate_currency.dart'; import 'package:stackwallet/pages/buy_view/sub_widgets/crypto_selection_view.dart'; +import 'package:stackwallet/providers/global/locale_provider.dart'; import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/widgets/loading_indicator.dart'; -class ExchangeTextField extends StatefulWidget { +class ExchangeTextField extends ConsumerStatefulWidget { const ExchangeTextField({ Key? key, this.borderRadius = 0, @@ -55,10 +57,10 @@ class ExchangeTextField extends StatefulWidget { final AggregateCurrency? currency; @override - State createState() => _ExchangeTextFieldState(); + ConsumerState createState() => _ExchangeTextFieldState(); } -class _ExchangeTextFieldState extends State { +class _ExchangeTextFieldState extends ConsumerState { late final TextEditingController controller; late final FocusNode focusNode; late final TextStyle textStyle; @@ -130,12 +132,17 @@ class _ExchangeTextFieldState extends State { ), ), inputFormatters: [ - // regex to validate a crypto amount with 8 decimal places - TextInputFormatter.withFunction((oldValue, newValue) => - RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') - .hasMatch(newValue.text) - ? newValue - : oldValue), + AmountInputFormatter( + decimals: 8, // todo change this + locale: ref.watch(localeServiceChangeNotifierProvider + .select((value) => value.locale)), + ), + // // regex to validate a crypto amount with 8 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,8}|[,.][0-9]{0,8})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), ], ), ), diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart index 7af988def..7591473b5 100644 --- a/test/utilities/amount/amount_unit_test.dart +++ b/test/utilities/amount/amount_unit_test.dart @@ -28,7 +28,7 @@ void main() { coin: Coin.bitcoin, maxDecimalPlaces: 8, ), - "10123.45678 mBTC", + "10,123.45678 mBTC", ); expect( @@ -38,7 +38,7 @@ void main() { coin: Coin.bitcoin, maxDecimalPlaces: 8, ), - "10123456.78 µBTC", + "10,123,456.78 µBTC", ); expect( @@ -48,7 +48,7 @@ void main() { coin: Coin.bitcoin, maxDecimalPlaces: 8, ), - "1012345678 sats", + "1,012,345,678 sats", ); final dec = Decimal.parse("10.123456789123456789"); @@ -98,7 +98,7 @@ void main() { coin: Coin.ethereum, maxDecimalPlaces: 9, ), - "~10123.456789123 mETH", + "~10,123.456789123 mETH", ); expect( @@ -108,7 +108,7 @@ void main() { coin: Coin.ethereum, maxDecimalPlaces: 8, ), - "~10123456.78912345 µETH", + "~10,123,456.78912345 µETH", ); expect( @@ -118,7 +118,7 @@ void main() { coin: Coin.ethereum, maxDecimalPlaces: 1, ), - "~10123456789.1 gwei", + "~10,123,456,789.1 gwei", ); expect( @@ -128,7 +128,7 @@ void main() { coin: Coin.ethereum, maxDecimalPlaces: 18, ), - "10123456789123.456789 mwei", + "10,123,456,789,123.456789 mwei", ); expect( @@ -138,7 +138,7 @@ void main() { coin: Coin.ethereum, maxDecimalPlaces: 4, ), - "10123456789123456.789 kwei", + "10,123,456,789,123,456.789 kwei", ); expect( @@ -148,7 +148,78 @@ void main() { coin: Coin.ethereum, maxDecimalPlaces: 1, ), - "10123456789123456789 wei", + "10,123,456,789,123,456,789 wei", + ); + }); + + test("parse eth string to amount", () { + final Amount amount = Amount.fromDecimal( + Decimal.parse("10.123456789123456789"), + fractionDigits: Coin.ethereum.decimals, + ); + + expect( + AmountUnit.nano.tryParse( + "~10,123,456,789.1 gwei", + locale: "en_US", + coin: Coin.ethereum, + ), + Amount.fromDecimal( + Decimal.parse("10.1234567891"), + fractionDigits: Coin.ethereum.decimals, + ), + ); + + expect( + AmountUnit.atto.tryParse( + "10,123,456,789,123,456,789 wei", + locale: "en_US", + coin: Coin.ethereum, + ), + amount, + ); + }); + + test("parse btc string to amount", () { + final Amount amount = Amount( + rawValue: BigInt.from(1012345678), + fractionDigits: 8, + ); + + expect( + AmountUnit.normal.tryParse( + "10.12345678 BTC", + locale: "en_US", + coin: Coin.bitcoin, + ), + amount, + ); + + expect( + AmountUnit.milli.tryParse( + "10,123.45678 mBTC", + locale: "en_US", + coin: Coin.bitcoin, + ), + amount, + ); + + expect( + AmountUnit.micro.tryParse( + "10,123,456.7822 µBTC", + locale: "en_US", + coin: Coin.bitcoin, + ), + amount, + ); + + expect( + AmountUnit.nano.tryParse( + "1,012,345,678 sats", + locale: "en_US", + coin: Coin.bitcoin, + ), + amount, ); }); }