fix(mobile): parse locale-aware transaction amounts (#2130)

* fix(mobile): parse locale-aware transaction amounts

* fix(mobile): tighten localized amount parsing
This commit is contained in:
ghost
2026-06-04 12:52:42 -07:00
committed by GitHub
parent fe47c918bb
commit eb27d36063
6 changed files with 352 additions and 86 deletions

View File

@@ -7,6 +7,7 @@ import '../providers/accounts_provider.dart';
import '../providers/transactions_provider.dart';
import '../providers/auth_provider.dart';
import '../services/log_service.dart';
import '../utils/amount_parser.dart';
class CalendarScreen extends StatefulWidget {
const CalendarScreen({super.key});
@@ -88,10 +89,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
final transactions = transactionsProvider.transactions;
_log.info('CalendarScreen', 'Loaded ${transactions.length} transactions for account ${_selectedAccount!.name}');
if (transactions.isNotEmpty) {
_log.debug('CalendarScreen', 'Sample transaction - name: ${transactions.first.name}, amount: ${transactions.first.amount}, nature: ${transactions.first.nature}');
}
// Store transactions for date filtering
_transactions = List.from(transactions);
@@ -114,23 +111,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
final date = DateTime.parse(transaction.date);
final dateKey = DateFormat('yyyy-MM-dd').format(date);
// Parse amount with proper sign handling
String trimmedAmount = transaction.amount.trim();
trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); // Normalize minus sign
// Detect if the amount has a negative sign
bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-');
// Remove all non-numeric characters except decimal point and minus sign
String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), '');
// Parse the numeric value
double amount = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0;
// Apply the sign from the string
if (hasNegativeSign) {
amount = -amount;
}
var amount = AmountParser.parse(transaction.amount).value;
// For asset accounts, flip the sign to match accounting conventions
// For liability accounts, also flip the sign
@@ -138,12 +119,12 @@ class _CalendarScreenState extends State<CalendarScreen> {
amount = -amount;
}
_log.debug('CalendarScreen', 'Processing transaction ${transaction.name} - date: $dateKey, raw amount: ${transaction.amount}, parsed: $amount, isAsset: ${_selectedAccount?.isAsset}, isLiability: ${_selectedAccount?.isLiability}');
_log.debug('CalendarScreen', 'Processing transaction date: $dateKey, parsed amount sign adjusted');
changes[dateKey] = (changes[dateKey] ?? 0.0) + amount;
_log.debug('CalendarScreen', 'Date $dateKey now has total: ${changes[dateKey]}');
} catch (e) {
_log.error('CalendarScreen', 'Failed to parse transaction date: ${transaction.date}, error: $e');
} catch (_) {
_log.error('CalendarScreen', 'Failed to process transaction for calendar');
}
}
@@ -248,9 +229,12 @@ class _CalendarScreenState extends State<CalendarScreen> {
Widget _buildTransactionTile(Transaction transaction) {
// Parse amount to determine if positive or negative
String trimmedAmount = transaction.amount.trim();
trimmedAmount = trimmedAmount.replaceAll('\u2212', '-');
bool isNegative = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-');
var isNegative = false;
try {
isNegative = AmountParser.parse(transaction.amount).value < 0;
} on FormatException {
// Keep the dialog renderable if the server returns a malformed amount.
}
// For asset accounts, flip the sign interpretation
if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) {

View File

@@ -6,6 +6,7 @@ import '../models/account.dart';
import '../providers/transactions_provider.dart';
import '../providers/accounts_provider.dart';
import '../providers/auth_provider.dart';
import '../utils/amount_parser.dart';
class RecentTransactionsScreen extends StatefulWidget {
const RecentTransactionsScreen({super.key});
@@ -176,35 +177,28 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
final account = _getAccount(transaction.accountId);
final accountName = account?.name ?? 'Unknown Account';
// Parse amount with proper sign handling (same logic as transactions_list_screen.dart)
String trimmedAmount = transaction.amount.trim();
trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); // Normalize minus sign
// Detect if the amount has a negative sign
bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-');
// Remove all non-numeric characters except decimal point and minus sign
String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), '');
// Parse the numeric value
double amount = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0;
// Apply the sign from the string
if (hasNegativeSign) {
amount = -amount;
double? amount;
try {
amount = AmountParser.parse(transaction.amount).value;
} on FormatException {
// Keep the list renderable if the server returns a malformed amount.
}
// For asset accounts and liability accounts, flip the sign to match accounting conventions
if (account?.isAsset == true || account?.isLiability == true) {
if (amount != null &&
(account?.isAsset == true || account?.isLiability == true)) {
amount = -amount;
}
// Determine display properties based on final amount
final isPositive = amount >= 0;
final isPositive = amount == null || amount >= 0;
Color amountColor;
String sign;
if (isPositive) {
if (amount == null) {
amountColor = Colors.grey;
sign = '';
} else if (isPositive) {
amountColor = Colors.green.shade700;
sign = '+';
} else {
@@ -225,13 +219,19 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isPositive
? Colors.green.withValues(alpha: 0.1)
: Colors.red.withValues(alpha: 0.1),
color: amount == null
? Colors.grey.withValues(alpha: 0.1)
: isPositive
? Colors.green.withValues(alpha: 0.1)
: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isPositive ? Icons.arrow_upward : Icons.arrow_downward,
amount == null
? Icons.help_outline
: isPositive
? Icons.arrow_upward
: Icons.arrow_downward,
color: amountColor,
),
),
@@ -274,7 +274,9 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
],
),
trailing: Text(
'$sign${transaction.currency} ${_formatAmount(amount.abs())}',
amount == null
? transaction.amount
: '$sign${transaction.currency} ${_formatAmount(amount.abs())}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,

View File

@@ -8,6 +8,7 @@ import '../providers/categories_provider.dart';
import '../providers/transactions_provider.dart';
import '../services/log_service.dart';
import '../services/connectivity_service.dart';
import '../utils/amount_parser.dart';
class TransactionFormScreen extends StatefulWidget {
final Account account;
@@ -66,8 +67,10 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
return 'Please enter an amount';
}
final amount = double.tryParse(value.trim());
if (amount == null) {
final double amount;
try {
amount = AmountParser.parse(value, locale: _currentLocaleName()).value;
} on FormatException {
return 'Please enter a valid number';
}
@@ -78,6 +81,11 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
return null;
}
String _currentLocaleName() {
return Localizations.maybeLocaleOf(context)?.toString() ??
Intl.getCurrentLocale();
}
Future<void> _selectDate() async {
final DateTime? picked = await showDatePicker(
context: context,
@@ -126,6 +134,10 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
// Convert date format from yyyy/MM/dd to yyyy-MM-dd
final parsedDate = DateFormat('yyyy/MM/dd').parse(_dateController.text);
final apiDate = DateFormat('yyyy-MM-dd').format(parsedDate);
final canonicalAmount = AmountParser.canonicalize(
_amountController.text,
locale: _currentLocaleName(),
);
_log.info('TransactionForm', 'Calling TransactionsProvider.createTransaction (offline-first)');
@@ -135,7 +147,7 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
accountId: widget.account.id,
name: _nameController.text.trim(),
date: apiDate,
amount: _amountController.text.trim(),
amount: canonicalAmount,
currency: widget.account.currency,
nature: _nature,
notes: 'This transaction via mobile app.',

View File

@@ -10,6 +10,7 @@ import '../screens/transaction_form_screen.dart';
import '../widgets/category_filter.dart';
import '../widgets/sync_status_badge.dart';
import '../services/log_service.dart';
import '../utils/amount_parser.dart';
class TransactionsListScreen extends StatefulWidget {
final Account account;
@@ -39,52 +40,32 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
// Amount is a currency-formatted string returned by the API (e.g. may include
// currency symbol, grouping separators, locale-dependent decimal separator,
// and a sign either before or after the symbol)
Map<String, dynamic> _getAmountDisplayInfo(String amount, bool isAsset) {
Map<String, dynamic> _getAmountDisplayInfo(
String amount,
bool isAsset,
bool isLiability,
) {
try {
// Trim whitespace
String trimmedAmount = amount.trim();
final parsed = AmountParser.parse(amount);
var numericValue = parsed.value;
// Normalize common minus characters (U+002D HYPHEN-MINUS, U+2212 MINUS SIGN)
trimmedAmount = trimmedAmount.replaceAll('\u2212', '-');
// Detect if the amount has a negative sign (leading or trailing)
bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-');
// Remove all non-numeric characters except decimal point and minus sign
String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), '');
// Parse the numeric value
double numericValue = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0;
// Apply the sign from the string
if (hasNegativeSign) {
numericValue = -numericValue;
}
// For asset accounts, flip the sign to match accounting conventions
if (isAsset) {
// For asset and liability accounts, flip the sign to match accounting conventions
if (isAsset || isLiability) {
numericValue = -numericValue;
}
// Determine if the final value is positive
bool isPositive = numericValue >= 0;
// Get the display amount by removing the sign and currency symbols
String displayAmount = trimmedAmount
.replaceAll('-', '')
.replaceAll('\u2212', '')
.trim();
return {
'isPositive': isPositive,
'displayAmount': displayAmount,
'displayAmount': parsed.displayText,
'color': isPositive ? Colors.green : Colors.red,
'icon': isPositive ? Icons.arrow_upward : Icons.arrow_downward,
'prefix': isPositive ? '' : '-',
};
} catch (e) {
// Fallback if parsing fails - log and return neutral state
LogService.instance.error('TransactionsListScreen', 'Failed to parse amount "$amount": $e');
} on FormatException {
LogService.instance.error('TransactionsListScreen', 'Failed to parse transaction amount');
return {
'isPositive': true,
'displayAmount': amount,
@@ -496,6 +477,7 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
final displayInfo = _getAmountDisplayInfo(
transaction.amount,
widget.account.isAsset,
widget.account.isLiability,
);
return Dismissible(

View File

@@ -0,0 +1,204 @@
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);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sure_mobile/utils/amount_parser.dart';
void main() {
group('AmountParser', () {
test('parses decimal-comma currency values', () {
final amount = AmountParser.parse('Rp1.234,56', locale: 'id_ID');
expect(amount.value, 1234.56);
expect(amount.canonicalValue, '1234.56');
expect(amount.displayText, 'Rp1.234,56');
});
test('parses grouped zero-decimal locale values as whole amounts', () {
final amount = AmountParser.parse('1.234.567', locale: 'id_ID');
expect(amount.value, 1234567);
expect(amount.canonicalValue, '1234567');
});
test('parses decimal-dot currency values', () {
final amount = AmountParser.parse(r'$1,234.56', locale: 'en_US');
expect(amount.value, 1234.56);
expect(amount.canonicalValue, '1234.56');
expect(amount.displayText, r'$1,234.56');
});
test('uses locale separators for ambiguous single separators', () {
expect(
AmountParser.parse('1.234', locale: 'id_ID').canonicalValue,
'1234',
);
expect(
AmountParser.parse('1.234', locale: 'en_US').canonicalValue,
'1.234',
);
});
test('normalizes minus variants and removes sign from display text', () {
final amount = AmountParser.parse('\u2212Rp1.234,50', locale: 'id_ID');
expect(amount.value, -1234.5);
expect(amount.canonicalValue, '-1234.5');
expect(amount.displayText, 'Rp1.234,50');
});
test('removes embedded and trailing signs from display text', () {
expect(
AmountParser.parse('Rp-1.234,50', locale: 'id_ID').displayText,
'Rp1.234,50',
);
expect(
AmountParser.parse('Rp1.234,50-', locale: 'id_ID').displayText,
'Rp1.234,50',
);
});
test('canonicalizes transaction form input for API payloads', () {
expect(AmountParser.canonicalize('1.234,50', locale: 'id_ID'), '1234.5');
expect(AmountParser.canonicalize('1,234.50', locale: 'en_US'), '1234.5');
});
test('rejects repeated separators that are not grouped', () {
expect(
() => AmountParser.parse('1.2.3', locale: 'en_US'),
throwsFormatException,
);
expect(
() => AmountParser.parse('1,2,3', locale: 'id_ID'),
throwsFormatException,
);
});
test('rejects inputs without digits', () {
expect(
() => AmountParser.parse('USD', locale: 'en_US'),
throwsFormatException,
);
});
});
}