What changed, and why it matters
This commit rewrites how the Stack Wallet app handles typed-in money amounts. It makes the rules for what counts as a valid amount much stricter: only digits and the user's locale-specific decimal separator are allowed, grouping characters (like commas in 1,000) are rejected, and the code now keeps a separate 'canonical' parser for trusted sources like QR codes. The change also adds recovery logic for on-screen keyboards that compose characters in unusual ways, so the app doesn't get stuck with an unparseable amount. The main risk is that a user could previously enter an amount that the app misread by a factor of 1,000 (for example, treating '1.000' as one thousand instead of one), which could lead to sending far more cryptocurrency than intended.
Treat this as a security-hardening change that likely fixes real-world mis-send risk. Review the new Amount.tryParseEditable* and tryParseCanonical* boundaries to ensure no caller still feeds display-formatted or grouped strings into editable parsers. Verify that the IME recovery cache is keyed safely per controller/configuration and cannot leak state between fields. Run the updated test suite and add additional locale matrix tests for any supported locales not yet covered. Consider whether externally supplied amounts (QR/URI) correctly use the canonical parser with truncateOverprecision where appropriate.
Security signals we found
Locale-based decimal/grouping separator confusion (1.000 vs 1,000) is removed from editable parsing
Display-formatted strings are no longer re-parsed as input, closing a class of format-string confusion bugs
Strict ASCII canonical parser added for externally supplied amounts (QR/URI) with optional overprecision truncation
IME composition recovery added to prevent the input field from becoming trapped in an unparseable state
AmountInputFormatter now bound to TextEditingController to survive widget rebuilds during composition
Tests explicitly reject grouping characters, signs, whitespace, and ambiguous dot-group forms
Evidence from the diff
The patch refactors amount parsing and formatting in a Flutter/Dart wallet app. It introduces explicit editable vs. canonical parsing paths: Amount.tryParseEditable accepts only digits plus the locale’s DECIMAL_SEP, while Amount.tryParseCanonical accepts strict ASCII dot-decimal strings for QR/URI amounts. Group separators are no longer accepted in editable input, and display-formatted strings (grouped, unit-suffixed, ‘~’-prefixed) are explicitly rejected by tryParse. AmountInputFormatter is rewritten to use a controller-bound recovery cache for IME composition, validates input against a locale-specific RegExp, and strips invalid characters by taking a valid prefix rather than joining surrounding digits. New relocalization helpers rewrite the decimal separator when the app locale changes. Tests are updated to assert the new strict behavior and IME recovery paths.
Changed components
lib/utilities/amount/amount.dartlib/utilities/amount/amount_input_formatter.dartlib/utilities/amount/amount_unit.dartlib/utilities/amount/amount_formatter.dartlib/utilities/amount/amount_field_relocalization.dartlib/utilities/util.dartlib/pages/send_view/send_view.dartlib/pages/send_view/token_send_view.dartlib/pages/send_view/sol_token_send_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dartlib/widgets/textfields/exchange_textfield.dartInspect captured patch +1135 / −428
diff --git a/lib/pages/salvium_stake/salvium_create_stake_view.dart b/lib/pages/salvium_stake/salvium_create_stake_view.dart
index 58a9a5a..2f2de59 100644
--- a/lib/pages/salvium_stake/salvium_create_stake_view.dart
+++ b/lib/pages/salvium_stake/salvium_create_stake_view.dart
@@ -357,6 +357,7 @@ class _SalviumCreateStakeViewState
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: _amountController,
decimals: coin.fractionDigits,
unit: ref.watch(pAmountUnit(coin)),
locale: locale,
diff --git a/lib/pages/send_view/frost_ms/recipient.dart b/lib/pages/send_view/frost_ms/recipient.dart
index 7e48372..ccfb33d 100644
--- a/lib/pages/send_view/frost_ms/recipient.dart
+++ b/lib/pages/send_view/frost_ms/recipient.dart
@@ -402,6 +402,7 @@ class _RecipientState extends ConsumerState<Recipient> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: amountController,
decimals: widget.coin.fractionDigits,
unit: ref.watch(pAmountUnit(widget.coin)),
locale: locale,
diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart
index ffc8fbb..c31e58a 100644
--- a/lib/pages/send_view/send_view.dart
+++ b/lib/pages/send_view/send_view.dart
@@ -2241,6 +2241,7 @@ class _SendViewState extends ConsumerState<SendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: coin.fractionDigits,
unit: ref.watch(pAmountUnit(coin)),
locale: locale,
@@ -2311,6 +2312,7 @@ class _SendViewState extends ConsumerState<SendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: locale,
),
diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart
index 36b027f..036266c 100644
--- a/lib/pages/send_view/sol_token_send_view.dart
+++ b/lib/pages/send_view/sol_token_send_view.dart
@@ -1088,6 +1088,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: tokenWallet.tokenDecimals,
unit: AmountUnit.normal,
locale: locale,
@@ -1146,6 +1147,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: locale,
),
diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart
index 9277117..3d2a56b 100644
--- a/lib/pages/send_view/token_send_view.dart
+++ b/lib/pages/send_view/token_send_view.dart
@@ -990,6 +990,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: tokenContract.decimals,
unit: ref.watch(pAmountUnit(coin)),
locale: locale,
@@ -1057,6 +1058,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: locale,
),
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 b799d10..cff1054 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
@@ -414,6 +414,7 @@ class _TransactionSearchViewState
),
inputFormatters: [
AmountInputFormatter(
+ controller: _amountTextEditingController,
decimals: widget.coin.fractionDigits,
unit: ref.watch(pAmountUnit(widget.coin)),
locale: ref.watch(
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 58d6826..9b0bf87 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
@@ -1616,6 +1616,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: coin.fractionDigits,
unit: ref.watch(pAmountUnit(coin)),
locale: locale,
@@ -1675,7 +1676,11 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
textAlign: TextAlign.right,
inputFormatters: [
- AmountInputFormatter(decimals: 2, locale: locale),
+ AmountInputFormatter(
+ controller: baseAmountController,
+ 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})$')
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
index 76cc736..b416d39 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
@@ -762,6 +762,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: tokenWallet.tokenDecimals,
unit: AmountUnit.normal,
locale: ref.watch(
@@ -834,6 +835,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: ref.watch(
localeServiceChangeNotifierProvider.select(
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 bd77a9b..0813d1c 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
@@ -722,6 +722,7 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: tokenContract.decimals,
unit: ref.watch(pAmountUnit(coin)),
locale: ref.watch(
@@ -794,6 +795,7 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: ref.watch(
localeServiceChangeNotifierProvider.select(
diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart
index 9c259f8..c123fc1 100644
--- a/lib/utilities/amount/amount.dart
+++ b/lib/utilities/amount/amount.dart
@@ -72,10 +72,6 @@ class Amount {
'^(?:$integerPattern(?:$escapedDecimal\\d+)?|$escapedDecimal\\d+)\$',
);
- // In locales that group digits with ".", a single-dot value with exactly
- // three trailing digits ("1.123") is both a validly grouped integer and a
- // plausible plain dot-decimal amount. Money input must not guess between
- // readings that differ 1000x, so such values are rejected outright.
if (groupSeparator == "." &&
decimalSeparator != "." &&
!value.contains(decimalSeparator) &&
@@ -97,17 +93,122 @@ class Amount {
return null;
}
- static Amount? tryParseFiatString(String value, {required String locale}) {
- if (value.isEmpty || value.contains(RegExp(r'[+\-\x09-\x0D ]'))) {
+ static Decimal? tryParseEditableDecimal(
+ String value, {
+ required String locale,
+ }) {
+ return _tryParseCanonicalDecimal(
+ _normalizeEditableDecimal(value, locale: locale),
+ );
+ }
+
+ static Amount? tryParseEditableAmount(
+ String value, {
+ required String locale,
+ required int fractionDigits,
+ }) {
+ return tryParseCanonicalAmount(
+ _normalizeEditableDecimal(value, locale: locale),
+ fractionDigits: fractionDigits,
+ );
+ }
+
+ static final RegExp _canonicalDecimalPattern = RegExp(
+ r'^(?:\d+(?:\.\d+)?|\.\d+)$',
+ );
+
+ static Decimal? _tryParseCanonicalDecimal(String value) {
+ if (!_canonicalDecimalPattern.hasMatch(value)) {
return null;
}
- // get number symbols for decimal place and group separator
- return Decimal.tryParse(
- normalizeLocalizedNumber(value, locale: locale),
- )?.toAmount(fractionDigits: 2);
+ return Decimal.tryParse(value);
}
+ static Amount? tryParseCanonicalAmount(
+ String value, {
+ required int fractionDigits,
+ bool truncateOverprecision = false,
+ }) {
+ if (fractionDigits < 0) {
+ return null;
+ }
+
+ final decimal = _tryParseCanonicalDecimal(value);
+ if (decimal == null) {
+ return null;
+ }
+
+ // Excess trailing zeros are fine as long as the value stays exactly
+ // representable; the isInteger check below rejects real overprecision
+ // unless the caller opted into truncation (e.g. externally supplied
+ // QR/URI amounts).
+ final atomicValue = decimal.shift(fractionDigits);
+ if (!atomicValue.isInteger && !truncateOverprecision) {
+ return null;
+ }
+
+ return Amount(
+ rawValue: atomicValue.toBigInt(),
+ fractionDigits: fractionDigits,
+ );
+ }
+
+ static String formatEditableDecimal(Decimal value, {required String locale}) {
+ return value.toString().replaceFirst(".", _decimalSeparator(locale));
+ }
+
+ static String formatFixedDecimal(
+ Decimal value, {
+ required int fractionDigits,
+ required String locale,
+ }) {
+ if (fractionDigits < 0) {
+ throw ArgumentError.value(fractionDigits, "fractionDigits");
+ }
+ return value
+ .toStringAsFixed(fractionDigits)
+ .replaceFirst(".", _decimalSeparator(locale));
+ }
+
+ static String relocalizeEditableDecimal(
+ String value, {
+ required String sourceLocale,
+ required String targetLocale,
+ }) {
+ return value.replaceAll(
+ _decimalSeparator(sourceLocale),
+ _decimalSeparator(targetLocale),
+ );
+ }
+
+ static Amount? tryParseFiatString(String value, {required String locale}) {
+ return tryParseEditableAmount(value, locale: locale, fractionDigits: 2);
+ }
+
+ static String _normalizeEditableDecimal(
+ String value, {
+ required String locale,
+ }) {
+ final decimalSeparator = _decimalSeparator(locale);
+ // Editable input accepts only the locale's decimal separator.
+ if (decimalSeparator != "." && value.contains(".")) {
+ return "";
+ }
+ String normalized = value.replaceAll(decimalSeparator, ".");
+ // A single trailing separator after digits reads as the number typed so
+ // far ("1." == 1); separator-only input stays invalid.
+ if (normalized.endsWith(".") &&
+ normalized.length > 1 &&
+ !normalized.substring(0, normalized.length - 1).contains(".")) {
+ normalized = normalized.substring(0, normalized.length - 1);
+ }
+ return normalized;
+ }
+
+ static String _decimalSeparator(String locale) =>
+ Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? ".";
+
// ===========================================================================
// ======= Instance properties ===============================================
diff --git a/lib/utilities/amount/amount_field_relocalization.dart b/lib/utilities/amount/amount_field_relocalization.dart
new file mode 100644
index 0000000..2e9634e
--- /dev/null
+++ b/lib/utilities/amount/amount_field_relocalization.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../providers/global/locale_provider.dart';
+import 'amount.dart';
+
+typedef ProviderListen<T> =
+ void Function(
+ ProviderListenable<T> provider,
+ void Function(T? previous, T next) listener,
+ );
+
+/// Rewrites a controller's decimal separator while preserving its selection.
+void relocalizeAmountController(
+ TextEditingController controller, {
+ required String sourceLocale,
+ required String targetLocale,
+}) {
+ final value = controller.value;
+ final text = Amount.relocalizeEditableDecimal(
+ value.text,
+ sourceLocale: sourceLocale,
+ targetLocale: targetLocale,
+ );
+ if (text == value.text) return;
+
+ int mapOffset(int offset) {
+ int safeOffset = offset;
+ if (safeOffset < 0) {
+ safeOffset = 0;
+ } else if (safeOffset > value.text.length) {
+ safeOffset = value.text.length;
+ }
+ return Amount.relocalizeEditableDecimal(
+ value.text.substring(0, safeOffset),
+ sourceLocale: sourceLocale,
+ targetLocale: targetLocale,
+ ).length;
+ }
+
+ final selection = value.selection.isValid
+ ? TextSelection(
+ baseOffset: mapOffset(value.selection.baseOffset),
+ extentOffset: mapOffset(value.selection.extentOffset),
+ affinity: value.selection.affinity,
+ isDirectional: value.selection.isDirectional,
+ )
+ : value.selection;
+ controller.value = value.copyWith(
+ text: text,
+ selection: selection,
+ composing: TextRange.empty,
+ );
+}
+
+/// Rewrites the decimal separator in [controllers] when the app locale
+/// changes so their text stays parseable by the locale-strict amount
+/// parsers, then invokes [onRelocalized] so the caller can re-run any
+/// parsing/validation that cached state from the old text.
+///
+/// Must be called from a widget's build method with `ref.listen`.
+void listenForAmountRelocalization(
+ ProviderListen<String> listen, {
+ required List<TextEditingController> controllers,
+ VoidCallback? onRelocalized,
+}) {
+ listen(localeServiceChangeNotifierProvider.select((value) => value.locale), (
+ previous,
+ next,
+ ) {
+ if (previous == null || previous == next) return;
+ for (final controller in controllers) {
+ relocalizeAmountController(
+ controller,
+ sourceLocale: previous,
+ targetLocale: next,
+ );
+ }
+ onRelocalized?.call();
+ });
+}
diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart
index c03c487..c2f70a0 100644
--- a/lib/utilities/amount/amount_formatter.dart
+++ b/lib/utilities/amount/amount_formatter.dart
@@ -66,13 +66,20 @@ class AmountFormatter {
);
}
- Amount? tryParse(String string, {Contract? tokenContract}) {
+ String formatEditable(Amount amount) {
+ return unit.formatEditable(amount: amount, locale: locale);
+ }
+
+ Amount? tryParseEditable(String string, {Contract? tokenContract}) {
return unit.tryParse(
string,
locale: locale,
coin: coin,
tokenContract: tokenContract,
- strict: true,
);
}
+
+ // Compatibility shim for callers migrated in later commits.
+ Amount? tryParse(String string, {Contract? tokenContract}) =>
+ tryParseEditable(string, tokenContract: tokenContract);
}
diff --git a/lib/utilities/amount/amount_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart
index 8237b50..24271ca 100644
--- a/lib/utilities/amount/amount_input_formatter.dart
+++ b/lib/utilities/amount/amount_input_formatter.dart
@@ -1,138 +1,206 @@
import 'dart:math';
import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
import '../util.dart';
import 'amount_unit.dart';
class AmountInputFormatter extends TextInputFormatter {
+ final TextEditingController controller;
final int decimals;
final String locale;
final AmountUnit? unit;
AmountInputFormatter({
+ required this.controller,
required this.decimals,
required this.locale,
this.unit,
- });
+ }) : assert(decimals >= 0);
+
+ late final String _decimalSeparator =
+ Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? ".";
+
+ late final int _maximumFractionDigits = unit == null
+ ? max(decimals, 0)
+ : max(decimals - unit!.shift, 0);
+
+ // Formatters are frequently constructed inline in build methods that
+ // rebuild per keystroke, so the compiled patterns are cached globally
+ // instead of per instance.
+ static final Map<(String, int), RegExp> _patternCache = {};
+
+ late final RegExp _validPattern = _patternCache.putIfAbsent(
+ (_decimalSeparator, _maximumFractionDigits),
+ () => _maximumFractionDigits == 0
+ ? RegExp(r'^\d*$')
+ : RegExp(
+ '^\\d*(?:${RegExp.escape(_decimalSeparator)}\\d{0,$_maximumFractionDigits})?\$',
+ ),
+ );
+
+ static final Expando<_AmountInputRecovery> _recoveryCache = Expando();
+
+ (String, int, int?) get _configuration => (locale, decimals, unit?.shift);
+
+ _AmountInputRecovery? get _activeRecovery {
+ final recovery = _recoveryCache[controller];
+ return recovery?.configuration == _configuration ? recovery : null;
+ }
+
+ bool _continuesActiveComposition(
+ _AmountInputRecovery recovery,
+ TextEditingValue oldValue,
+ ) =>
+ !oldValue.composing.isCollapsed &&
+ recovery.composingText == oldValue.text;
+
+ void _clearRecovery() => _recoveryCache[controller] = null;
+
+ void _rememberComposition(TextEditingValue value, String composingText) {
+ if (value.text.isEmpty) {
+ _clearRecovery();
+ return;
+ }
+
+ final selection = value.selection.isValid
+ ? TextSelection(
+ baseOffset: min(
+ max(value.selection.baseOffset, 0),
+ value.text.length,
+ ),
+ extentOffset: min(
+ max(value.selection.extentOffset, 0),
+ value.text.length,
+ ),
+ affinity: value.selection.affinity,
+ isDirectional: value.selection.isDirectional,
+ )
+ : TextSelection.collapsed(offset: value.text.length);
+ _recoveryCache[controller] = _AmountInputRecovery(
+ configuration: _configuration,
+ value: value.copyWith(selection: selection, composing: TextRange.empty),
+ composingText: composingText,
+ );
+ }
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
- // get number symbols for decimal place and group separator
- final numberSymbols = Util.getSymbolsFor(locale: locale);
-
- final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? ".";
- final groupSeparator = numberSymbols?.GROUP_SEP ?? ",";
- final oldSelection = oldValue.selection.isValid
- ? oldValue.selection
- : TextSelection.collapsed(offset: oldValue.text.length);
-
- String text = newValue.text;
- if (groupSeparator == "." && decimalSeparator != ".") {
- final insertedLength =
- newValue.text.length -
- oldValue.text.length +
- oldSelection.end -
- oldSelection.start;
- final insertedStart = oldSelection.start;
- final insertedEnd = insertedStart + insertedLength;
- if (insertedLength > 0 &&
- insertedStart >= 0 &&
- insertedEnd <= text.length) {
- final inserted = text.substring(insertedStart, insertedEnd);
- final isGrouped = RegExp(
- r'^[1-9]\d{0,2}(\.\d{3})+$',
- ).hasMatch(inserted);
- text =
- text.substring(0, insertedStart) +
- (inserted.contains(decimalSeparator) || isGrouped
- ? inserted
- : inserted.replaceAll(groupSeparator, decimalSeparator)) +
- text.substring(insertedEnd);
- }
- }
- final selectionEnd = newValue.selection.isValid
- ? min(newValue.selection.end, text.length)
- : text.length;
- final textBeforeSelection = text
- .substring(0, selectionEnd)
- .replaceAll(groupSeparator, "");
- String newText = text.replaceAll(groupSeparator, "");
- final selectionOffset = textBeforeSelection.length;
-
- String? fraction;
- if (newText.contains(decimalSeparator)) {
- final parts = newText.split(decimalSeparator);
-
- if (parts.length > 2) {
- return oldValue;
- }
-
- final fractionDigits = unit == null
- ? decimals
- : max(decimals - unit!.shift, 0);
-
- if (newText.startsWith(decimalSeparator)) {
- if (newText.length - 1 > fractionDigits) {
- newText = newText.substring(0, fractionDigits + 1);
+ if (!newValue.composing.isCollapsed) {
+ if (_validPattern.hasMatch(oldValue.text)) {
+ _rememberComposition(oldValue, newValue.text);
+ } else {
+ final recovery = _activeRecovery;
+ if (recovery != null &&
+ _continuesActiveComposition(recovery, oldValue)) {
+ _recoveryCache[controller] = _AmountInputRecovery(
+ configuration: _configuration,
+ value: recovery.value,
+ composingText: newValue.text,
+ );
+ } else {
+ _clearRecovery();
}
-
- return TextEditingValue(
- text: newText,
- selection: TextSelection.collapsed(
- offset: min(selectionOffset, newText.length),
- ),
- );
}
+ return newValue;
+ }
- newText = parts.first;
- if (parts.length == 2) {
- fraction = parts.last;
- } else {
- fraction = "";
- }
+ if (_validPattern.hasMatch(newValue.text)) {
+ _clearRecovery();
+ return newValue;
+ }
- if (fraction.length > fractionDigits) {
- fraction = fraction.substring(0, fractionDigits);
- }
+ final recovery = _activeRecovery;
+ if (recovery != null && _continuesActiveComposition(recovery, oldValue)) {
+ _clearRecovery();
+ return recovery.value;
}
- String newString;
- final val = BigInt.tryParse(newText);
- 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 ?? ","}",
- );
+ final oldTextIsValid = _validPattern.hasMatch(oldValue.text);
+ final isDeletingFromInvalidText =
+ !oldTextIsValid &&
+ newValue.text.length < oldValue.text.length &&
+ _canResultFromDeletion(oldValue.text, newValue.text);
+ if (isDeletingFromInvalidText) {
+ _clearRecovery();
+ return newValue;
}
- if (fraction != null) {
- newString += decimalSeparator;
- if (fraction.isNotEmpty) {
- newString += fraction;
- }
+ if (oldTextIsValid) {
+ _clearRecovery();
+ return oldValue;
}
- int formattedSelectionOffset = 0;
- int normalizedOffset = 0;
- while (formattedSelectionOffset < newString.length &&
- normalizedOffset < selectionOffset) {
- if (newString[formattedSelectionOffset] != groupSeparator) {
- normalizedOffset++;
+ // Both texts are invalid, but are not the commit of the active composing
+ // value. A remembered value from an unrelated edit (or an earlier
+ // formatter configuration) must not replace the current input.
+ _clearRecovery();
+ // Never strip characters from the middle — joining the surrounding digits
+ // would silently change the value ("1.5" must not become "15").
+ return _validPrefix(newValue);
+ }
+
+ TextEditingValue _validPrefix(TextEditingValue value) {
+ const asciiZeroCodeUnit = 0x30;
+ const asciiNineCodeUnit = 0x39;
+ bool separatorSeen = false;
+ int fractionDigits = 0;
+ int end = 0;
+ for (; end < value.text.length; end++) {
+ final char = value.text[end];
+ final codeUnit = char.codeUnitAt(0);
+ if (codeUnit >= asciiZeroCodeUnit && codeUnit <= asciiNineCodeUnit) {
+ if (separatorSeen) {
+ if (fractionDigits >= _maximumFractionDigits) break;
+ fractionDigits++;
+ }
+ } else if (!separatorSeen &&
+ _maximumFractionDigits > 0 &&
+ char == _decimalSeparator) {
+ separatorSeen = true;
+ } else {
+ break;
}
- formattedSelectionOffset++;
}
-
+ final text = value.text.substring(0, end);
return TextEditingValue(
- text: newString,
- selection: TextSelection.collapsed(offset: formattedSelectionOffset),
+ text: text,
+ selection: TextSelection.collapsed(
+ offset: min(
+ value.selection.isValid ? value.selection.end : text.length,
+ text.length,
+ ),
+ ),
);
}
+
+ bool _canResultFromDeletion(String oldText, String newText) {
+ int newIndex = 0;
+ for (
+ int oldIndex = 0;
+ oldIndex < oldText.length && newIndex < newText.length;
+ oldIndex++
+ ) {
+ if (oldText.codeUnitAt(oldIndex) == newText.codeUnitAt(newIndex)) {
+ newIndex++;
+ }
+ }
+ return newIndex == newText.length;
+ }
+}
+
+class _AmountInputRecovery {
+ final (String, int, int?) configuration;
+ final TextEditingValue value;
+ final String composingText;
+
+ const _AmountInputRecovery({
+ required this.configuration,
+ required this.value,
+ required this.composingText,
+ });
}
diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart
index 0f1cd5c..15414f9 100644
--- a/lib/utilities/amount/amount_unit.dart
+++ b/lib/utilities/amount/amount_unit.dart
@@ -196,44 +196,50 @@ extension AmountUnitExt on AmountUnit {
}
}
+ static final RegExp _rejectedInputChars = RegExp(r'[+\-~ \x09-\x0D]');
+
+ /// Parses user-editable amount text (digits plus the locale's decimal
+ /// separator only). Display strings (grouped, unit-suffixed, or
+ /// "~"-prefixed) are intentionally rejected: formatted display output
+ /// must never be re-parsed as input.
Amount? tryParse(
String value, {
required String locale,
required CryptoCurrency coin,
Contract? tokenContract,
+ // Compatibility-only parameters for callers migrated later in the
+ // series. Editable parsing is strict regardless of these values.
bool strict = false,
bool overrideWithDecimalPlacesFromString = false,
}) {
- if (value.contains(RegExp(r'[+\-\x09-\x0D]')) ||
- (strict && value.contains(" "))) {
+ if (value.contains(_rejectedInputChars)) {
return null;
}
- final precisionLost = value.startsWith("~");
-
- final parts = (precisionLost ? value.substring(1) : value).split(" ");
-
- if (parts.first.isEmpty) {
- return null;
- }
-
- final str = parts.first;
+ final decimalPlaces = tokenContract?.decimals ?? coin.fractionDigits;
+ final realShift = math.min(shift, decimalPlaces);
- // get number symbols for decimal place and group separator
- final Decimal? decimal = Decimal.tryParse(
- Amount.normalizeLocalizedNumber(str, locale: locale),
+ final parsedUnitAmount = Amount.tryParseEditableAmount(
+ value,
+ locale: locale,
+ fractionDigits: decimalPlaces - realShift,
);
-
- if (decimal == null) {
+ if (parsedUnitAmount == null) {
return null;
}
- final decimalPlaces = overrideWithDecimalPlacesFromString
- ? decimal.scale
- : tokenContract?.decimals ?? coin.fractionDigits;
- final realShift = math.min(shift, decimalPlaces);
+ return Amount(
+ rawValue: parsedUnitAmount.raw,
+ fractionDigits: decimalPlaces,
+ );
+ }
- return decimal.shift(0 - realShift).toAmount(fractionDigits: decimalPlaces);
+ String formatEditable({required Amount amount, required String locale}) {
+ final realShift = math.min(shift, amount.fractionDigits);
+ return Amount.formatEditableDecimal(
+ amount.decimal.shift(realShift),
+ locale: locale,
+ );
}
String displayAmount({
diff --git a/lib/utilities/util.dart b/lib/utilities/util.dart
index c722832..ec16286 100644
--- a/lib/utilities/util.dart
+++ b/lib/utilities/util.dart
@@ -28,15 +28,59 @@ abstract class Util {
static const isArmLinux = bool.fromEnvironment("IS_ARM");
static final isTestEnv = Platform.environment["FLUTTER_TEST"] == "true";
+ static final Map<String, NumberSymbols?> _numberSymbolsCache = {};
+
static Directory? libraryPath;
static double? screenWidth;
static NumberSymbols? getSymbolsFor({required String locale}) {
- return numberFormatSymbols[locale] as NumberSymbols? ??
- numberFormatSymbols[locale.replaceAll("-", "_")] as NumberSymbols? ??
- numberFormatSymbols[locale.substring(3).toLowerCase()]
- as NumberSymbols? ??
- numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?;
+ return _numberSymbolsCache.putIfAbsent(locale, () {
+ final exactSymbols = numberFormatSymbols[locale];
+ if (exactSymbols is NumberSymbols) {
+ return exactSymbols;
+ }
+
+ final localeParts = locale
+ .replaceAll("-", "_")
+ .split("_")
+ .where((part) => part.isNotEmpty)
+ .toList();
+ if (localeParts.isEmpty) {
+ return null;
+ }
+
+ final languageCode = localeParts.first.toLowerCase();
+ String? scriptCode;
+ String? regionCode;
+ for (final part in localeParts.skip(1)) {
+ if (scriptCode == null && RegExp(r'^[A-Za-z]{4}$').hasMatch(part)) {
+ scriptCode =
+ "${part[0].toUpperCase()}${part.substring(1).toLowerCase()}";
+ } else if (regionCode == null &&
+ RegExp(r'^(?:[A-Za-z]{2}|\d{3})$').hasMatch(part)) {
+ regionCode = part.toUpperCase();
+ }
+ }
+
+ final candidates = <String>{
+ [
+ languageCode,
+ if (scriptCode != null) scriptCode,
+ if (regionCode != null) regionCode,
+ ].join("_"),
+ if (regionCode != null) "${languageCode}_$regionCode",
+ if (scriptCode != null) "${languageCode}_$scriptCode",
+ languageCode,
+ };
+
+ for (final candidate in candidates) {
+ final symbols = numberFormatSymbols[candidate];
+ if (symbols is NumberSymbols) {
+ return symbols;
+ }
+ }
+ return null;
+ });
}
static bool get isDesktop {
diff --git a/lib/widgets/textfields/exchange_textfield.dart b/lib/widgets/textfields/exchange_textfield.dart
index 3f6dfc3..9dd9a28 100644
--- a/lib/widgets/textfields/exchange_textfield.dart
+++ b/lib/widgets/textfields/exchange_textfield.dart
@@ -135,6 +135,7 @@ class _ExchangeTextFieldState extends ConsumerState<ExchangeTextField> {
),
inputFormatters: [
AmountInputFormatter(
+ controller: controller,
decimals: 8, // todo change this
locale: ref.watch(
localeServiceChangeNotifierProvider
diff --git a/test/pages/send_view/sol_token_amount_parsing_test.dart b/test/pages/send_view/sol_token_amount_parsing_test.dart
index 542dabc..ab92fa9 100644
--- a/test/pages/send_view/sol_token_amount_parsing_test.dart
+++ b/test/pages/send_view/sol_token_amount_parsing_test.dart
@@ -4,37 +4,60 @@ import 'package:stackwallet/pages/send_view/sol_token_send_view.dart';
import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
void main() {
- test("mobile SPL inputs parse locale grouping", () {
- final token = SolContract(
- address: "mint",
- name: "Token",
- symbol: "TKN",
- decimals: 6,
- );
- final solana = Solana(CryptoCurrencyNetwork.main);
+ final token = SolContract(
+ address: "mint",
+ name: "Token",
+ symbol: "TKN",
+ decimals: 6,
+ );
+ final solana = Solana(CryptoCurrencyNetwork.main);
+ test("mobile SPL inputs use the locale decimal separator", () {
expect(
parseMobileSolTokenAmount(
- "1,000",
+ "1.25",
locale: "en_US",
coin: solana,
tokenContract: token,
)?.raw,
- BigInt.from(1000000000),
+ BigInt.from(1250000),
);
expect(
parseMobileSolTokenAmount(
- "1.000",
+ "1,25",
locale: "de_DE",
coin: solana,
tokenContract: token,
)?.raw,
- BigInt.from(1000000000),
+ BigInt.from(1250000),
+ );
+ expect(
+ parseMobileSolTokenFiatAmount("1,25", locale: "de_DE")?.raw,
+ BigInt.from(125),
);
+ });
+
+ test("mobile SPL inputs reject grouping and signs", () {
+ for (final value in ["1,000", "+1", "-1", " 1"]) {
+ expect(
+ parseMobileSolTokenAmount(
+ value,
+ locale: "en_US",
+ coin: solana,
+ tokenContract: token,
+ ),
+ isNull,
+ reason: value,
+ );
+ }
expect(
- parseMobileSolTokenFiatAmount("1,000", locale: "en_US")?.raw,
- BigInt.from(100000),
+ parseMobileSolTokenAmount(
+ "1.000",
+ locale: "de_DE",
+ coin: solana,
+ tokenContract: token,
+ ),
+ isNull,
);
- expect(parseMobileSolTokenFiatAmount("+1", locale: "en_US"), isNull);
});
}
diff --git a/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart b/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart
index 00a6580..6de3986 100644
--- a/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart
+++ b/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart
@@ -5,40 +5,52 @@ import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub
import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
void main() {
- test('desktop token inputs parse locale grouping', () {
- final token = SolContract(
- address: 'mint',
- name: 'Token',
- symbol: 'TKN',
- decimals: 6,
- );
- final solana = Solana(CryptoCurrencyNetwork.main);
+ final token = SolContract(
+ address: "mint",
+ name: "Token",
+ symbol: "TKN",
+ decimals: 6,
+ );
+ final solana = Solana(CryptoCurrencyNetwork.main);
+ test("desktop token inputs use the locale decimal separator", () {
expect(
parseDesktopSolTokenAmount(
- '1,000',
- locale: 'en_US',
+ "1.25",
+ locale: "en_US",
coin: solana,
tokenContract: token,
)?.raw,
- BigInt.from(1000000000),
- );
- expect(
- parseDesktopSolTokenFiatAmount('1,000', locale: 'en_US')?.raw,
- BigInt.from(100000),
- );
- expect(
- parseDesktopTokenFiatAmount('1.000', locale: 'de_DE')?.raw,
- BigInt.from(100000),
+ BigInt.from(1250000),
);
expect(
parseDesktopSolTokenAmount(
- '-1',
- locale: 'en_US',
+ "1,25",
+ locale: "de_DE",
coin: solana,
tokenContract: token,
- ),
- isNull,
+ )?.raw,
+ BigInt.from(1250000),
+ );
+ expect(
+ parseDesktopTokenFiatAmount("1,25", locale: "de_DE")?.raw,
+ BigInt.from(125),
);
});
+
+ test("desktop token inputs reject grouping and signs", () {
+ for (final value in ["1,000", "+1", "-1", " 1"]) {
+ expect(
+ parseDesktopSolTokenAmount(
+ value,
+ locale: "en_US",
+ coin: solana,
+ tokenContract: token,
+ ),
+ isNull,
+ reason: value,
+ );
+ }
+ expect(parseDesktopSolTokenFiatAmount("1.000", locale: "de_DE"), isNull);
+ });
}
diff --git a/test/utilities/amount/amount_field_relocalization_test.dart b/test/utilities/amount/amount_field_relocalization_test.dart
new file mode 100644
index 0000000..06d2bc6
--- /dev/null
+++ b/test/utilities/amount/amount_field_relocalization_test.dart
@@ -0,0 +1,32 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/utilities/amount/amount_field_relocalization.dart';
+
+void main() {
+ test('relocalizing an amount preserves its selection', () {
+ const selection = TextSelection(
+ baseOffset: 4,
+ extentOffset: 1,
+ affinity: TextAffinity.upstream,
+ isDirectional: true,
+ );
+ final controller = TextEditingController.fromValue(
+ const TextEditingValue(
+ text: '12.34',
+ selection: selection,
+ composing: TextRange(start: 2, end: 4),
+ ),
+ );
+ addTearDown(controller.dispose);
+
+ relocalizeAmountController(
+ controller,
+ sourceLocale: 'en_US',
+ targetLocale: 'de_DE',
+ );
+
+ expect(controller.text, '12,34');
+ expect(controller.selection, selection);
+ expect(controller.value.composing, TextRange.empty);
+ });
+}
diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart
index 807f4e6..52a6ccb 100644
--- a/test/utilities/amount/amount_unit_test.dart
+++ b/test/utilities/amount/amount_unit_test.dart
@@ -1,6 +1,7 @@
import 'package:decimal/decimal.dart';
-import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/models/isar/models/solana/sol_contract.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';
@@ -9,6 +10,12 @@ import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
void main() {
+ TextEditingController testController() {
+ final controller = TextEditingController();
+ addTearDown(controller.dispose);
+ return controller;
+ }
+
test("displayAmount BTC", () {
final Amount amount = Amount(
rawValue: BigInt.from(1012345678),
@@ -158,75 +165,46 @@ void main() {
);
});
- test("parse eth string to amount", () {
+ test("tryParse rejects display-formatted strings", () {
final eth = Ethereum(CryptoCurrencyNetwork.main);
- final Amount amount = Amount.fromDecimal(
- Decimal.parse("10.123456789123456789"),
- fractionDigits: eth.fractionDigits,
- );
+ final btc = Bitcoin(CryptoCurrencyNetwork.main);
+ // Display output (grouped, unit-suffixed, "~"-prefixed) is never
+ // valid input; only editable text parses.
expect(
AmountUnit.nano.tryParse(
"~10,123,456,789.1 gwei",
locale: "en_US",
coin: eth,
),
- Amount.fromDecimal(
- Decimal.parse("10.1234567891"),
- fractionDigits: eth.fractionDigits,
- ),
+ isNull,
);
-
expect(
- AmountUnit.atto.tryParse(
- "10,123,456,789,123,456,789 wei",
- locale: "en_US",
- coin: eth,
- ),
- amount,
+ AmountUnit.normal.tryParse("10.12345678 BTC", locale: "en_US", coin: btc),
+ isNull,
);
- });
-
- 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: Bitcoin(CryptoCurrencyNetwork.main),
- ),
- amount,
- );
-
expect(
AmountUnit.milli.tryParse(
"10,123.45678 mBTC",
locale: "en_US",
- coin: Bitcoin(CryptoCurrencyNetwork.main),
+ coin: btc,
),
- amount,
+ isNull,
);
expect(
- AmountUnit.micro.tryParse(
- "10,123,456.7822 µBTC",
- locale: "en_US",
- coin: Bitcoin(CryptoCurrencyNetwork.main),
- ),
- amount,
+ AmountUnit.normal
+ .tryParse("10.12345678", locale: "en_US", coin: btc)
+ ?.raw,
+ BigInt.from(1012345678),
);
-
expect(
- AmountUnit.nano.tryParse(
- "1,012,345,678 sats",
- locale: "en_US",
- coin: Bitcoin(CryptoCurrencyNetwork.main),
- ),
- amount,
+ AmountUnit.milli.tryParse("10123.45678", locale: "en_US", coin: btc)?.raw,
+ BigInt.from(1012345678),
+ );
+ expect(
+ AmountUnit.nano.tryParse("1012345678", locale: "en_US", coin: btc)?.raw,
+ BigInt.from(1012345678),
);
});
@@ -239,15 +217,16 @@ void main() {
maxDecimals: 8,
);
- expect(formatter.tryParse("5")?.decimal, Decimal.fromInt(5));
+ expect(formatter.tryParseEditable("5")?.decimal, Decimal.fromInt(5));
for (final value in [
"+5",
"-5",
+ "1,000",
for (final codePoint in [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20])
"1${String.fromCharCode(codePoint)}234",
]) {
- expect(formatter.tryParse(value), isNull, reason: value);
+ expect(formatter.tryParseEditable(value), isNull, reason: value);
expect(
Amount.tryParseFiatString(value, locale: "en_US"),
isNull,
@@ -255,277 +234,612 @@ void main() {
);
}
+ expect(formatter.tryParseEditable("0.000000001"), isNull);
+ expect(Amount.tryParseFiatString("1.001", locale: "en_US"), isNull);
+
expect(
- AmountUnit.normal
- .tryParse("5 legacy", locale: "en_US", coin: coin)
- ?.decimal,
- Decimal.fromInt(5),
+ AmountUnit.normal.tryParse("5 legacy", locale: "en_US", coin: coin),
+ isNull,
);
});
- test("parse ASCII decimals in dot-group locales", () {
+ test("strict parsing accepts only the locale decimal separator", () {
final coin = Bitcoin(CryptoCurrencyNetwork.main);
- final formatter = AmountInputFormatter(decimals: 8, locale: "de_DE");
+
+ // de_DE uses "," as its decimal separator, so ASCII dots are rejected.
+ for (final value in ["1.5", "1.234", "1.000", "10.000", "1.000,5"]) {
+ expect(
+ AmountUnit.normal.tryParse(value, locale: "de_DE", coin: coin),
+ isNull,
+ reason: value,
+ );
+ }
+ expect(Amount.tryParseFiatString("1.50", locale: "de_DE"), isNull);
+
+ // The locale's own decimal separator parses.
expect(
- AmountUnit.normal.tryParse("1.5", locale: "de_DE", coin: coin)?.decimal,
+ AmountUnit.normal.tryParse("1,5", locale: "de_DE", coin: coin)?.decimal,
Decimal.parse("1.5"),
);
expect(
- AmountUnit.normal.tryParse("1.234", locale: "de_DE", coin: coin)?.decimal,
- Decimal.fromInt(1234),
+ Amount.tryParseFiatString("1,50", locale: "de_DE")?.decimal,
+ Decimal.parse("1.5"),
);
expect(
- Amount.tryParseFiatString("1.50", locale: "de_DE")?.decimal,
- Decimal.parse("1.5"),
+ Amount.tryParseEditableDecimal("1,234", locale: "de_DE"),
+ Decimal.parse("1.234"),
);
- final formatted = formatter.formatEditUpdate(
- TextEditingValue.empty,
- const TextEditingValue(
- text: "1.5",
- selection: TextSelection.collapsed(offset: 3),
- ),
+
+ expect(
+ Util.getSymbolsFor(locale: "de-Latn-CH")?.DECIMAL_SEP,
+ Util.getSymbolsFor(locale: "de_CH")?.DECIMAL_SEP,
);
- expect(formatted.text, "1,5");
+ });
- final appended = formatter.formatEditUpdate(
- const TextEditingValue(
- text: "1.234",
- selection: TextSelection.collapsed(offset: 5),
+ test("formatter rejects non-decimal separators", () {
+ TextEditingValue edit(
+ AmountInputFormatter formatter,
+ String oldText,
+ String newText,
+ ) {
+ return formatter.formatEditUpdate(
+ TextEditingValue(
+ text: oldText,
+ selection: TextSelection.collapsed(offset: oldText.length),
+ ),
+ TextEditingValue(
+ text: newText,
+ selection: TextSelection.collapsed(offset: newText.length),
+ ),
+ );
+ }
+
+ final de = AmountInputFormatter(
+ controller: testController(),
+ decimals: 8,
+ locale: "de_DE",
+ );
+ expect(edit(de, "1", "1.").text, "1");
+ expect(edit(de, "1", "1,").text, "1,");
+ expect(edit(de, "", "1.200").text, "");
+ expect(edit(de, "", "1.200,5").text, "");
+
+ final us = AmountInputFormatter(
+ controller: testController(),
+ decimals: 8,
+ locale: "en_US",
+ );
+ expect(edit(us, "1", "1,").text, "1");
+ expect(edit(us, "1", "1.").text, "1.");
+ expect(edit(us, "", "1,200").text, "");
+ expect(edit(us, "", "1,200.5").text, "");
+ });
+
+ test("canonical and token parsing preserve exact precision", () {
+ final canonical = Amount.tryParseCanonicalAmount(
+ "1.234567",
+ fractionDigits: 6,
+ );
+ expect(canonical?.raw, BigInt.from(1234567));
+ // Excess trailing zeros still represent the exact value; only real
+ // sub-atomic precision is rejected.
+ expect(
+ Amount.tryParseCanonicalAmount("1.2345670", fractionDigits: 6)?.raw,
+ BigInt.from(1234567),
+ );
+ expect(
+ Amount.tryParseCanonicalAmount(
+ "0.0000000100000000",
+ fractionDigits: 8,
+ )?.raw,
+ BigInt.one,
+ );
+ expect(
+ Amount.tryParseCanonicalAmount("1.2345671", fractionDigits: 6),
+ isNull,
+ );
+ expect(Amount.tryParseCanonicalAmount("1", fractionDigits: -1), isNull);
+
+ // Externally supplied QR/URI amounts may opt into truncation instead of
+ // rejection.
+ expect(
+ Amount.tryParseCanonicalAmount(
+ "0.123456789",
+ fractionDigits: 8,
+ truncateOverprecision: true,
+ )?.raw,
+ BigInt.from(12345678),
+ );
+ expect(
+ Amount.tryParseCanonicalAmount(
+ "1.2345671",
+ fractionDigits: 6,
+ truncateOverprecision: true,
+ )?.raw,
+ BigInt.from(1234567),
+ );
+ // Truncation never loosens the grammar itself.
+ expect(
+ Amount.tryParseCanonicalAmount(
+ "1e-3",
+ fractionDigits: 8,
+ truncateOverprecision: true,
),
- const TextEditingValue(
- text: "1.2345",
- selection: TextSelection.collapsed(offset: 6),
+ isNull,
+ );
+ expect(
+ Amount.tryParseCanonicalAmount(
+ "-1",
+ fractionDigits: 8,
+ truncateOverprecision: true,
),
+ isNull,
);
- expect(appended.text, "12.345");
- final insertedDecimal = formatter.formatEditUpdate(
- const TextEditingValue(
- text: "1.234",
- selection: TextSelection.collapsed(offset: 1),
- ),
+ final token = SolContract(
+ address: "mint",
+ name: "Token",
+ symbol: "TKN",
+ decimals: 6,
+ );
+ final parsedToken = AmountUnit.normal.tryParse(
+ "1000",
+ locale: "en_US",
+ coin: Solana(CryptoCurrencyNetwork.main),
+ tokenContract: token,
+ );
+ expect(parsedToken?.raw, BigInt.from(1000000000));
+ expect(parsedToken?.fractionDigits, 6);
+ });
+
+ test("formatter tolerates an invalid selection", () {
+ final formatter = AmountInputFormatter(
+ controller: testController(),
+ decimals: 8,
+ locale: "en_US",
+ );
+ final result = formatter.formatEditUpdate(
+ TextEditingValue.empty,
+ const TextEditingValue(text: "1234"),
+ );
+ expect(result.text, "1234");
+
+ const composingValue = TextEditingValue(
+ text: "1.",
+ selection: TextSelection.collapsed(offset: 2),
+ composing: TextRange(start: 1, end: 2),
+ );
+ final commaFormatter = AmountInputFormatter(
+ controller: testController(),
+ decimals: 8,
+ locale: "de_DE",
+ );
+ expect(
+ commaFormatter.formatEditUpdate(TextEditingValue.empty, composingValue),
+ composingValue,
+ );
+
+ // When the IME commits that invalid composing text, the formatter
+ // sanitizes instead of trapping the field in an unparseable state.
+ final committed = commaFormatter.formatEditUpdate(
+ composingValue,
const TextEditingValue(
- text: "1..234",
+ text: "1.",
selection: TextSelection.collapsed(offset: 2),
),
);
- expect(insertedDecimal.text, "1,234");
- expect(insertedDecimal.selection.baseOffset, 2);
+ expect(committed.text, "1");
+ expect(committed.composing, TextRange.empty);
});
- test("strict localized parsing validates grouping", () {
- expect(
- Amount.tryParseLocalizedNumber("1,000", locale: "en_US"),
- Decimal.fromInt(1000),
+ test("formatter restores valid text after rebuilding during IME input", () {
+ const valid = TextEditingValue(
+ text: "1,5",
+ selection: TextSelection.collapsed(offset: 3),
+ );
+ const composing = TextEditingValue(
+ text: "1.5",
+ selection: TextSelection.collapsed(offset: 3),
+ composing: TextRange(start: 1, end: 2),
+ );
+ const committed = TextEditingValue(
+ text: "1.5",
+ selection: TextSelection.collapsed(offset: 3),
+ );
+
+ final controller = testController();
+ final beforeRebuild = AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "de_DE",
+ );
+ expect(beforeRebuild.formatEditUpdate(valid, composing), composing);
+
+ final afterRebuild = AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "de_DE",
);
+ expect(afterRebuild.formatEditUpdate(composing, committed), valid);
+ });
+
+ test("formatter restores valid text when an IME changes text at commit", () {
+ const valid = TextEditingValue(
+ text: "1,5",
+ selection: TextSelection.collapsed(offset: 3),
+ );
+ const composing = TextEditingValue(
+ text: "1.5",
+ selection: TextSelection.collapsed(offset: 3),
+ composing: TextRange(start: 1, end: 2),
+ );
+ const changedCommit = TextEditingValue(
+ text: "1.",
+ selection: TextSelection.collapsed(offset: 2),
+ );
+
+ final controller = testController();
+ AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "de_DE",
+ ).formatEditUpdate(valid, composing);
+
expect(
- Amount.tryParseLocalizedNumber("1.5", locale: "en_US"),
- Decimal.parse("1.5"),
+ AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "de_DE",
+ ).formatEditUpdate(composing, changedCommit),
+ valid,
+ );
+ });
+
+ test("formatter recovery is isolated by controller and configuration", () {
+ TextEditingValue value(String text) => TextEditingValue(
+ text: text,
+ selection: TextSelection.collapsed(offset: text.length),
+ );
+
+ final controller = testController();
+ final de = AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "de_DE",
+ );
+ de.formatEditUpdate(TextEditingValue.empty, value("1,5"));
+
+ final otherController = AmountInputFormatter(
+ controller: testController(),
+ decimals: 8,
+ locale: "de_DE",
);
expect(
- Amount.tryParseLocalizedNumber("1,5", locale: "de_DE"),
- Decimal.parse("1.5"),
+ otherController.formatEditUpdate(value("2.5"), value("2.5")).text,
+ "2",
+ );
+
+ final otherLocale = AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "en_US",
+ );
+ expect(otherLocale.formatEditUpdate(value("2,5"), value("2,5")).text, "2");
+
+ final highPrecision = AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "en_US",
+ );
+ highPrecision.formatEditUpdate(TextEditingValue.empty, value("1.234"));
+ final lowPrecision = AmountInputFormatter(
+ controller: controller,
+ decimals: 2,
+ locale: "en_US",
);
expect(
- Amount.tryParseLocalizedNumber("1.5", locale: "de_DE"),
- Decimal.parse("1.5"),
+ lowPrecision.formatEditUpdate(value("1.234"), value("1.234")).text,
+ "1.23",
);
+ });
- for (final malformed in ["1,5", "12,34", "0,001", "1,,000"]) {
- expect(
- Amount.tryParseLocalizedNumber(malformed, locale: "en_US"),
- isNull,
- reason: malformed,
+ test(
+ "formatter does not restore stale recovery after a config round trip",
+ () {
+ TextEditingValue value(String text) => TextEditingValue(
+ text: text,
+ selection: TextSelection.collapsed(offset: text.length),
);
- }
- });
- test("ambiguous dot-grouped values are rejected", () {
- // A single "." group with exactly three trailing digits reads as both a
- // grouped integer (1123) and a dot-decimal amount (1.123). Reject.
- for (final ambiguous in ["1.123", "1.000", "12.345", "999.999"]) {
+ final controller = testController();
+ final fiat = AmountInputFormatter(
+ controller: controller,
+ decimals: 2,
+ locale: "en_US",
+ );
+ fiat.formatEditUpdate(TextEditingValue.empty, value("50"));
+
+ final highPrecision = AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "en_US",
+ );
+ highPrecision.formatEditUpdate(value("50"), value("0.12345"));
+
+ final fiatAgain = AmountInputFormatter(
+ controller: controller,
+ decimals: 2,
+ locale: "en_US",
+ );
expect(
- Amount.tryParseLocalizedNumber(ambiguous, locale: "de_DE"),
- isNull,
- reason: ambiguous,
+ fiatAgain.formatEditUpdate(value("0.12345"), value("0.123456")).text,
+ "0.12",
);
- }
+ },
+ );
+
+ test("formatter clears recovery and isolates amount-unit shifts", () {
+ TextEditingValue value(String text) => TextEditingValue(
+ text: text,
+ selection: TextSelection.collapsed(offset: text.length),
+ );
- // Values with only one possible reading still parse.
+ final controller = testController();
+ final de = AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "de_DE",
+ );
+ de.formatEditUpdate(TextEditingValue.empty, value("1,5"));
+ de.formatEditUpdate(value("1,5"), TextEditingValue.empty);
expect(
- Amount.tryParseLocalizedNumber("1.12", locale: "de_DE"),
- Decimal.parse("1.12"),
+ AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "de_DE",
+ ).formatEditUpdate(value("2.5"), value("2.5")).text,
+ "2",
);
+
+ final normal = AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "en_US",
+ unit: AmountUnit.normal,
+ );
+ normal.formatEditUpdate(TextEditingValue.empty, value("1.123456"));
expect(
- Amount.tryParseLocalizedNumber("1.1234", locale: "de_DE"),
- Decimal.parse("1.1234"),
+ AmountInputFormatter(
+ controller: controller,
+ decimals: 8,
+ locale: "en_US",
+ unit: AmountUnit.milli,
+ ).formatEditUpdate(value("1.123456"), value("1.123456")).text,
+ "1.12345",
+ );
+ });
+
+ test("formatter never joins digits around stripped invalid characters", () {
+ TextEditingValue committedInvalid(String text, {int? caret}) {
+ return TextEditingValue(
+ text: text,
+ selection: TextSelection.collapsed(offset: caret ?? text.length),
+ );
+ }
+
+ // Both old and new values invalid (IME commit path): the recovered text
+ // must be a prefix of the committed text, never digits joined across a
+ // stripped character ("1.5" must not become "15").
+ final de = AmountInputFormatter(
+ controller: testController(),
+ decimals: 8,
+ locale: "de_DE",
);
expect(
- Amount.tryParseLocalizedNumber("0.123", locale: "de_DE"),
- Decimal.parse("0.123"),
+ de
+ .formatEditUpdate(committedInvalid("1.5"), committedInvalid("1.5"))
+ .text,
+ "1",
);
expect(
- Amount.tryParseLocalizedNumber("1234.123", locale: "de_DE"),
- Decimal.parse("1234.123"),
+ de
+ .formatEditUpdate(committedInvalid("1e3"), committedInvalid("1e3"))
+ .text,
+ "1",
);
expect(
- Amount.tryParseLocalizedNumber("1.000.000", locale: "de_DE"),
- Decimal.fromInt(1000000),
+ de.formatEditUpdate(committedInvalid("-5"), committedInvalid("-5")).text,
+ "",
);
expect(
- Amount.tryParseLocalizedNumber("1.000,5", locale: "de_DE"),
- Decimal.parse("1000.5"),
+ de
+ .formatEditUpdate(
+ committedInvalid("abc12", caret: 3),
+ committedInvalid("abc12", caret: 3),
+ )
+ .text,
+ "",
);
- // Locales with "." as the decimal separator are unaffected.
- expect(
- Amount.tryParseLocalizedNumber("1.123", locale: "en_US"),
- Decimal.parse("1.123"),
+ // A formatter that admitted an invalid composing edit restores the valid
+ // value from immediately before that composition.
+ final usController = testController();
+ final us = AmountInputFormatter(
+ controller: usController,
+ decimals: 8,
+ locale: "en_US",
);
+ final valid = committedInvalid("1.5");
+ const composingInvalid = TextEditingValue(
+ text: "1,5",
+ selection: TextSelection.collapsed(offset: 3),
+ composing: TextRange(start: 1, end: 2),
+ );
+ expect(us.formatEditUpdate(valid, composingInvalid), composingInvalid);
expect(
- Amount.tryParseLocalizedNumber("1,123", locale: "en_US"),
- Decimal.fromInt(1123),
+ AmountInputFormatter(
+ controller: usController,
+ decimals: 8,
+ locale: "en_US",
+ ).formatEditUpdate(composingInvalid, committedInvalid("1,5")).text,
+ "1.5",
);
});
- test("tryParseLocalizedNumber input class matrix", () {
- // (input, expected for en_US, expected for de_DE); null means rejected.
- final cases = <(String, String?, String?)>[
- // Plain integers.
- ("0", "0", "0"),
- ("5", "5", "5"),
- ("007", "7", "7"),
- (
- "1234567890123456789012345678901234567890",
- "1234567890123456789012345678901234567890",
- "1234567890123456789012345678901234567890",
- ),
- // Decimal-separator forms.
- ("1.5", "1.5", "1.5"),
- ("0.5", "0.5", "0.5"),
- (".5", "0.5", "0.5"),
- ("00.5", "0.5", "0.5"),
- ("1.12345678", "1.12345678", "1.12345678"),
- ("1,5", null, "1.5"),
- (",5", null, "0.5"),
- (",000", null, "0"),
- ("0,5", null, "0.5"),
- ("1,12345678", null, "1.12345678"),
- ("0.000000000000000001", "0.000000000000000001", "0.000000000000000001"),
- // Grouped values; note a 3-digit comma "decimal" is valid in de_DE.
- ("1,000", "1000", "1"),
- ("10,000", "10000", "10"),
- ("100,000", "100000", "100"),
- ("999,999", "999999", "999.999"),
- ("1,234,567", "1234567", null),
- ("1,000.5", "1000.5", null),
- ("1,000,000.12345678", "1000000.12345678", null),
- ("1.234.567", null, "1234567"),
- ("1.000,5", null, "1000.5"),
- ("1.000.000,12345678", null, "1000000.12345678"),
- // Malformed grouping (en_US); most re-read as decimals in de_DE.
- ("1,23", null, "1.23"),
- ("12,3456", null, "12.3456"),
- ("1234,567", null, "1234.567"),
- ("0,001", null, "0.001"),
- ("1,0000", null, "1"),
- ("1,,000", null, null),
- ("1,000,00", null, null),
- // Ambiguous single dot group in de_DE; plain decimals in en_US.
- ("1.123", "1.123", null),
- ("1.000", "1", null),
- ("12.345", "12.345", null),
- ("999.999", "999.999", null),
- // Unambiguous dot forms in de_DE.
- ("1.12", "1.12", "1.12"),
- ("1.1234", "1.1234", "1.1234"),
- ("0.123", "0.123", "0.123"),
- ("1000.123", "1000.123", "1000.123"),
- ("1234.123", "1234.123", "1234.123"),
- // Separator garbage.
- ("1.2.3", null, null),
- ("1..5", null, null),
- (".", null, null),
- ("..", null, null),
- (",", null, null),
- ("1.", null, null),
- ("5.", null, null),
- ("5,", null, null),
- ("1,000.", null, null),
- ("1.000.", null, null),
- (".5.5", null, null),
- // Signs and whitespace.
- ("", null, null),
- ("+5", null, null),
- ("-5", null, null),
- ("5-", null, null),
- ("1-2", null, null),
- (" 5", null, null),
- ("5 ", null, null),
- ("1 000", null, null),
- ("\t5", null, null),
- ("5\n", null, null),
- ("5\r", null, null),
- // Non-numeric and exotic digits.
- ("abc", null, null),
- ("1a", null, null),
- ("a1", null, null),
- ("1e5", null, null),
- ("1E5", null, null),
- ("0x10", null, null),
- ("NaN", null, null),
- ("Infinity", null, null),
- ("١٢٣", null, null),
- ("123", null, null),
- ];
-
- for (final (input, enExpected, deExpected) in cases) {
- expect(
- Amount.tryParseLocalizedNumber(input, locale: "en_US"),
- enExpected == null ? isNull : Decimal.parse(enExpected),
- reason: "en_US: '$input'",
- );
+ test("editable parsers accept one trailing decimal separator", () {
+ expect(Amount.tryParseEditableDecimal("1.", locale: "en_US"), Decimal.one);
+ expect(Amount.tryParseEditableDecimal("1,", locale: "de_DE"), Decimal.one);
+ expect(
+ AmountUnit.normal
+ .tryParse(
+ "10.",
+ locale: "en_US",
+ coin: Bitcoin(CryptoCurrencyNetwork.main),
+ )
+ ?.raw,
+ BigInt.from(1000000000),
+ );
+ // Separator-only and doubled separators stay invalid.
+ for (final (value, locale) in [
+ (".", "en_US"),
+ (",", "de_DE"),
+ ("1..", "en_US"),
+ (".5.", "en_US"),
+ ]) {
expect(
- Amount.tryParseLocalizedNumber(input, locale: "de_DE"),
- deExpected == null ? isNull : Decimal.parse(deExpected),
- reason: "de_DE: '$input'",
+ Amount.tryParseEditableDecimal(value, locale: locale),
+ isNull,
+ reason: "$locale '$value'",
);
}
+ // Canonical parsing stays strict.
+ expect(Amount.tryParseCanonicalAmount("1.", fractionDigits: 8), isNull);
});
- test("tryParseLocalizedNumber locale symbols and fallback defaults", () {
- // fr_FR groups with a non-breaking space variant; build input from the
- // actual symbol so the test survives intl data updates.
- final frGroup = Util.getSymbolsFor(locale: "fr_FR")!.GROUP_SEP;
+ test("formatEditableDecimal writes locale-editable text", () {
expect(
- Amount.tryParseLocalizedNumber("1${frGroup}234,5", locale: "fr_FR"),
- Decimal.parse("1234.5"),
+ Amount.formatEditableDecimal(Decimal.parse("1.5"), locale: "en_US"),
+ "1.5",
);
expect(
- Amount.tryParseLocalizedNumber("1234,5", locale: "fr_FR"),
- Decimal.parse("1234.5"),
+ Amount.formatEditableDecimal(Decimal.parse("1.5"), locale: "de_DE"),
+ "1,5",
);
- // A typed ASCII space is never a valid separator.
- expect(Amount.tryParseLocalizedNumber("1 000", locale: "fr_FR"), isNull);
+ // no grouping, ever: editable text must parse back via tryParseEditable*
+ expect(
+ Amount.formatEditableDecimal(
+ Decimal.parse("1234567.89"),
+ locale: "de_DE",
+ ),
+ "1234567,89",
+ );
+ expect(
+ Amount.tryParseEditableDecimal(
+ Amount.formatEditableDecimal(
+ Decimal.parse("1234567.89"),
+ locale: "de_DE",
+ ),
+ locale: "de_DE",
+ ),
+ Decimal.parse("1234567.89"),
+ );
+ });
- // Unknown locale falls back to "," grouping and "." decimals.
+ test("formatFixedDecimal writes locale-editable fixed text", () {
expect(
- Amount.tryParseLocalizedNumber("1,000.5", locale: "zz_ZZ"),
- Decimal.parse("1000.5"),
+ Amount.formatFixedDecimal(
+ Decimal.parse("1.5"),
+ fractionDigits: 3,
+ locale: "de_DE",
+ ),
+ "1,500",
);
expect(
- Amount.tryParseLocalizedNumber("1.5", locale: "zz_ZZ"),
- Decimal.parse("1.5"),
+ Amount.formatFixedDecimal(
+ Decimal.parse("1.5"),
+ fractionDigits: 2,
+ locale: "en_US",
+ ),
+ "1.50",
+ );
+ expect(
+ () => Amount.formatFixedDecimal(
+ Decimal.one,
+ fractionDigits: -1,
+ locale: "en_US",
+ ),
+ throwsArgumentError,
);
- expect(Amount.tryParseLocalizedNumber("1,5", locale: "zz_ZZ"), isNull);
});
- test("formatter tolerates an invalid selection", () {
- final formatter = AmountInputFormatter(decimals: 8, locale: "en_US");
- final result = formatter.formatEditUpdate(
- TextEditingValue.empty,
- const TextEditingValue(text: "1234"),
+ test("formatEditable round-trips through tryParseEditable", () {
+ final coin = Bitcoin(CryptoCurrencyNetwork.main);
+ final amount = Amount(rawValue: BigInt.from(1012345678), fractionDigits: 8);
+
+ for (final locale in ["en_US", "de_DE"]) {
+ for (final unit in [
+ AmountUnit.normal,
+ AmountUnit.milli,
+ AmountUnit.nano,
+ ]) {
+ final formatter = AmountFormatter(
+ unit: unit,
+ locale: locale,
+ coin: coin,
+ maxDecimals: 8,
+ );
+ final text = formatter.formatEditable(amount);
+ expect(
+ formatter.tryParseEditable(text),
+ amount,
+ reason: "$locale $unit $text",
+ );
+ }
+ }
+
+ expect(
+ AmountUnit.normal.formatEditable(amount: amount, locale: "de_DE"),
+ "10,12345678",
+ );
+ expect(
+ AmountUnit.nano.formatEditable(amount: amount, locale: "de_DE"),
+ "1012345678",
+ );
+ });
+
+ test("relocalizeEditableDecimal rewrites the decimal separator", () {
+ expect(
+ Amount.relocalizeEditableDecimal(
+ "1,5",
+ sourceLocale: "de_DE",
+ targetLocale: "en_US",
+ ),
+ "1.5",
+ );
+ expect(
+ Amount.relocalizeEditableDecimal(
+ "1.5",
+ sourceLocale: "en_US",
+ targetLocale: "de_DE",
+ ),
+ "1,5",
+ );
+ expect(
+ Amount.relocalizeEditableDecimal(
+ "",
+ sourceLocale: "en_US",
+ targetLocale: "de_DE",
+ ),
+ "",
+ );
+ // same separator locales: unchanged
+ expect(
+ Amount.relocalizeEditableDecimal(
+ "1.5",
+ sourceLocale: "en_US",
+ targetLocale: "en_GB",
+ ),
+ "1.5",
);
- expect(result.text, "1,234");
});
}
Why this scored 61/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.