mirror of
https://github.com/we-promise/sure.git
synced 2026-06-05 18:59:04 +00:00
* fix(mobile): parse locale-aware transaction amounts * fix(mobile): tighten localized amount parsing
205 lines
5.2 KiB
Dart
205 lines
5.2 KiB
Dart
import 'package:intl/intl.dart';
|
|
|
|
class ParsedAmount {
|
|
const ParsedAmount({
|
|
required this.value,
|
|
required this.canonicalValue,
|
|
required this.displayText,
|
|
});
|
|
|
|
final double value;
|
|
final String canonicalValue;
|
|
final String displayText;
|
|
}
|
|
|
|
class AmountParser {
|
|
static ParsedAmount parse(String input, {String? locale}) {
|
|
final normalized = _normalizeMinus(input.trim());
|
|
final negative = _isNegative(normalized);
|
|
final numericText = normalized.replaceAll(RegExp(r'[^0-9.,]'), '');
|
|
|
|
if (!RegExp(r'\d').hasMatch(numericText)) {
|
|
throw const FormatException('Amount must contain digits');
|
|
}
|
|
|
|
final separators = _Separators.forLocale(locale);
|
|
final decimalSeparator = _decimalSeparatorFor(numericText, separators);
|
|
final canonical = _canonicalize(
|
|
numericText,
|
|
decimalSeparator: decimalSeparator,
|
|
negative: negative,
|
|
);
|
|
|
|
return ParsedAmount(
|
|
value: double.parse(canonical),
|
|
canonicalValue: canonical,
|
|
displayText: _displayText(normalized),
|
|
);
|
|
}
|
|
|
|
static String canonicalize(String input, {String? locale}) {
|
|
return parse(input, locale: locale).canonicalValue;
|
|
}
|
|
|
|
static String _normalizeMinus(String value) {
|
|
return value
|
|
.replaceAll('\u2212', '-')
|
|
.replaceAll('\u2012', '-')
|
|
.replaceAll('\u2013', '-')
|
|
.replaceAll('\u2014', '-');
|
|
}
|
|
|
|
static bool _isNegative(String value) {
|
|
final trimmed = value.trim();
|
|
return trimmed.contains('-') ||
|
|
(trimmed.startsWith('(') && trimmed.endsWith(')'));
|
|
}
|
|
|
|
static String _displayText(String value) {
|
|
var display = value.trim();
|
|
|
|
if (display.startsWith('(') && display.endsWith(')')) {
|
|
display = display.substring(1, display.length - 1).trim();
|
|
}
|
|
|
|
display = display.replaceAll(RegExp(r'[-+]'), '').trim();
|
|
|
|
return display;
|
|
}
|
|
|
|
static String _canonicalize(
|
|
String numericText, {
|
|
required String? decimalSeparator,
|
|
required bool negative,
|
|
}) {
|
|
String integerDigits;
|
|
String fractionDigits = '';
|
|
|
|
if (decimalSeparator == null) {
|
|
integerDigits = numericText.replaceAll(RegExp(r'\D'), '');
|
|
} else {
|
|
final decimalIndex = numericText.lastIndexOf(decimalSeparator);
|
|
integerDigits =
|
|
numericText.substring(0, decimalIndex).replaceAll(RegExp(r'\D'), '');
|
|
fractionDigits =
|
|
numericText.substring(decimalIndex + 1).replaceAll(RegExp(r'\D'), '');
|
|
}
|
|
|
|
integerDigits = integerDigits.replaceFirst(RegExp(r'^0+(?=\d)'), '');
|
|
if (integerDigits.isEmpty) {
|
|
integerDigits = '0';
|
|
}
|
|
|
|
fractionDigits = fractionDigits.replaceFirst(RegExp(r'0+$'), '');
|
|
|
|
final unsigned = fractionDigits.isEmpty
|
|
? integerDigits
|
|
: '$integerDigits.$fractionDigits';
|
|
|
|
if (!negative || unsigned == '0') {
|
|
return unsigned;
|
|
}
|
|
|
|
return '-$unsigned';
|
|
}
|
|
|
|
static String? _decimalSeparatorFor(
|
|
String numericText,
|
|
_Separators separators,
|
|
) {
|
|
final lastDot = numericText.lastIndexOf('.');
|
|
final lastComma = numericText.lastIndexOf(',');
|
|
|
|
if (lastDot == -1 && lastComma == -1) {
|
|
return null;
|
|
}
|
|
|
|
if (lastDot != -1 && lastComma != -1) {
|
|
return lastDot > lastComma ? '.' : ',';
|
|
}
|
|
|
|
final separator = lastDot == -1 ? ',' : '.';
|
|
final parts = numericText.split(separator);
|
|
|
|
if (parts.length > 2) {
|
|
if (_looksGrouped(parts)) {
|
|
return null;
|
|
}
|
|
|
|
throw const FormatException('Invalid amount format');
|
|
}
|
|
|
|
if (separator == separators.decimalSeparator) {
|
|
return separator;
|
|
}
|
|
|
|
final lastGroupLength = parts.last.length;
|
|
if (separator == separators.groupSeparator && lastGroupLength == 3) {
|
|
return null;
|
|
}
|
|
|
|
if (_looksGrouped(parts)) {
|
|
return null;
|
|
}
|
|
|
|
return separator;
|
|
}
|
|
|
|
static bool _looksGrouped(List<String> parts) {
|
|
if (parts.length < 2 || parts.first.isEmpty || parts.first.length > 3) {
|
|
return false;
|
|
}
|
|
|
|
return parts.skip(1).every((part) => part.length == 3);
|
|
}
|
|
}
|
|
|
|
class _Separators {
|
|
const _Separators({
|
|
required this.decimalSeparator,
|
|
required this.groupSeparator,
|
|
});
|
|
|
|
final String decimalSeparator;
|
|
final String groupSeparator;
|
|
|
|
static _Separators forLocale(String? locale) {
|
|
final effectiveLocale = locale ?? Intl.getCurrentLocale();
|
|
|
|
try {
|
|
final symbols = NumberFormat.decimalPattern(effectiveLocale).symbols;
|
|
return _Separators(
|
|
decimalSeparator: symbols.DECIMAL_SEP,
|
|
groupSeparator: symbols.GROUP_SEP,
|
|
);
|
|
} catch (_) {
|
|
if (_usesDecimalComma(effectiveLocale)) {
|
|
return const _Separators(decimalSeparator: ',', groupSeparator: '.');
|
|
}
|
|
|
|
return const _Separators(decimalSeparator: '.', groupSeparator: ',');
|
|
}
|
|
}
|
|
|
|
// Fallback heuristic for common decimal-comma locales when NumberFormat's
|
|
// locale database cannot resolve the requested locale. Keep the set limited
|
|
// to languages we intentionally support and extend it with tests as needed.
|
|
static bool _usesDecimalComma(String locale) {
|
|
final language = locale.split(RegExp(r'[-_]')).first.toLowerCase();
|
|
return {
|
|
'ca',
|
|
'de',
|
|
'es',
|
|
'fr',
|
|
'hu',
|
|
'id',
|
|
'it',
|
|
'nl',
|
|
'pl',
|
|
'pt',
|
|
'ro',
|
|
'tr',
|
|
}.contains(language);
|
|
}
|
|
}
|