diff --git a/mobile/lib/screens/calendar_screen.dart b/mobile/lib/screens/calendar_screen.dart index 611d54f86..65ccc335c 100644 --- a/mobile/lib/screens/calendar_screen.dart +++ b/mobile/lib/screens/calendar_screen.dart @@ -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 { 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 { 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 { 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 { 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) { diff --git a/mobile/lib/screens/recent_transactions_screen.dart b/mobile/lib/screens/recent_transactions_screen.dart index 8decc3071..546cea286 100644 --- a/mobile/lib/screens/recent_transactions_screen.dart +++ b/mobile/lib/screens/recent_transactions_screen.dart @@ -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 { 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 { 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 { ], ), 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, diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart index d5513615b..5198b320c 100644 --- a/mobile/lib/screens/transaction_form_screen.dart +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -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 { 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 { return null; } + String _currentLocaleName() { + return Localizations.maybeLocaleOf(context)?.toString() ?? + Intl.getCurrentLocale(); + } + Future _selectDate() async { final DateTime? picked = await showDatePicker( context: context, @@ -126,6 +134,10 @@ class _TransactionFormScreenState extends State { // 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 { 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.', diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 0aa6ab131..3d0411e38 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -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 { // 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 _getAmountDisplayInfo(String amount, bool isAsset) { + Map _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 { final displayInfo = _getAmountDisplayInfo( transaction.amount, widget.account.isAsset, + widget.account.isLiability, ); return Dismissible( diff --git a/mobile/lib/utils/amount_parser.dart b/mobile/lib/utils/amount_parser.dart new file mode 100644 index 000000000..aa1cf662a --- /dev/null +++ b/mobile/lib/utils/amount_parser.dart @@ -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 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); + } +} diff --git a/mobile/test/utils/amount_parser_test.dart b/mobile/test/utils/amount_parser_test.dart new file mode 100644 index 000000000..2d21152e6 --- /dev/null +++ b/mobile/test/utils/amount_parser_test.dart @@ -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, + ); + }); + }); +}