diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 5c13953..c90a8a4 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -74,6 +74,44 @@ class AppCategories { }; } +enum AmountFormat { commasDot, spacesDot, plain } + +extension AmountFormatExt on AmountFormat { + String get label { + switch (this) { + case AmountFormat.commasDot: return '1,234,567.89'; + case AmountFormat.spacesDot: return '1 234 567.89'; + case AmountFormat.plain: return '1234567.89'; + } + } + + String get example { + switch (this) { + case AmountFormat.commasDot: return 'SYM 1,234.56'; + case AmountFormat.spacesDot: return 'SYM 1 234.56'; + case AmountFormat.plain: return 'SYM 1234.56'; + } + } + + String format(double amount) { + switch (this) { + case AmountFormat.commasDot: + // groups of 3 with commas, dot decimal + final parts = amount.toStringAsFixed(2).split('.'); + final intPart = parts[0].replaceAllMapped( + RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},'); + return '$intPart.${parts[1]}'; + case AmountFormat.spacesDot: + final parts = amount.toStringAsFixed(2).split('.'); + final intPart = parts[0].replaceAllMapped( + RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]} '); + return '$intPart.${parts[1]}'; + case AmountFormat.plain: + return amount.toStringAsFixed(2); + } + } +} + class CurrencyOption { final String symbol; final String name; diff --git a/lib/features/categories/screen.dart b/lib/features/categories/screen.dart index 450f3a8..e798235 100644 --- a/lib/features/categories/screen.dart +++ b/lib/features/categories/screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../core/constants.dart'; import '../../shared/utils/currency_utils.dart'; +import '../../shared/providers/amount_format_provider.dart'; import '../settings/provider.dart'; import 'provider.dart'; @@ -180,7 +181,7 @@ class _ToggleButton extends StatelessWidget { } } -class _PieChartCard extends StatelessWidget { +class _PieChartCard extends ConsumerWidget { final Map data; final double total; final int touchedIndex; @@ -196,7 +197,8 @@ class _PieChartCard extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); final entries = data.entries.toList(); return Container( @@ -258,7 +260,7 @@ class _PieChartCard extends StatelessWidget { ), ), Text( - formatAmount(currency, total), + formatAmount(currency, total, fmt), style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w700, @@ -275,13 +277,14 @@ class _PieChartCard extends StatelessWidget { } } -class _BarChartCard extends StatelessWidget { +class _BarChartCard extends ConsumerWidget { final List monthlyData; final String currency; const _BarChartCard({required this.monthlyData, required this.currency}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); final maxY = monthlyData.map((e) => e.amount).reduce((a, b) => a > b ? a : b); final adjustedMaxY = maxY * 1.2; @@ -312,7 +315,7 @@ class _BarChartCard extends StatelessWidget { touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( - formatAmount(currency, rod.toY), + formatAmount(currency, rod.toY, fmt), TextStyle( color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.w600, @@ -390,7 +393,7 @@ class _BarChartCard extends StatelessWidget { } } -class _CategoryRow extends StatelessWidget { +class _CategoryRow extends ConsumerWidget { final int rank; final String category; final double amount; @@ -405,7 +408,8 @@ class _CategoryRow extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); final color = AppCategories.colors[category] ?? AppColors.accent; final icon = AppCategories.icons[category] ?? Icons.category_rounded; final pct = total > 0 ? amount / total : 0.0; @@ -459,7 +463,7 @@ class _CategoryRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - formatAmount(currency, amount), + formatAmount(currency, amount, fmt), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppColors.expense, fontWeight: FontWeight.w700, diff --git a/lib/features/dashboard/screen.dart b/lib/features/dashboard/screen.dart index 5202fba..1491a6c 100644 --- a/lib/features/dashboard/screen.dart +++ b/lib/features/dashboard/screen.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart'; import '../../core/constants.dart'; import '../../shared/models/transaction.dart'; import '../../shared/utils/currency_utils.dart'; +import '../../shared/providers/amount_format_provider.dart'; import '../settings/provider.dart'; import 'provider.dart'; @@ -245,7 +246,7 @@ class _FilterChip extends StatelessWidget { } } -class _BudgetProgress extends StatelessWidget { +class _BudgetProgress extends ConsumerWidget { final double spent; final double budget; final CurrencyInfo currencyInfo; @@ -257,7 +258,8 @@ class _BudgetProgress extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); final ratio = spent / budget; final color = ratio >= 1.0 ? AppColors.expense @@ -308,13 +310,13 @@ class _BudgetProgress extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Spent: ${formatAmount(currencyInfo.symbol, spent)}', + 'Spent: ${formatAmount(currencyInfo.symbol, spent, fmt)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), ), ), Text( - 'Limit: ${formatAmount(currencyInfo.symbol, budget)}', + 'Limit: ${formatAmount(currencyInfo.symbol, budget, fmt)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), ), @@ -327,14 +329,15 @@ class _BudgetProgress extends StatelessWidget { } } -class _BudgetWarning extends StatelessWidget { +class _BudgetWarning extends ConsumerWidget { final double spent; final double budget; final CurrencyInfo currencyInfo; const _BudgetWarning({required this.spent, required this.budget, required this.currencyInfo}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); final over = spent - budget; final isDark = Theme.of(context).brightness == Brightness.dark; return Container( @@ -350,7 +353,7 @@ class _BudgetWarning extends StatelessWidget { const SizedBox(width: 10), Expanded( child: Text( - 'Budget exceeded by ${formatAmount(currencyInfo.symbol, over)}', + 'Budget exceeded by ${formatAmount(currencyInfo.symbol, over, fmt)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.expense, fontWeight: FontWeight.w600, @@ -371,6 +374,7 @@ class _BalanceCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final rates = ref.read(exchangeRateServiceProvider); + final fmt = ref.watch(amountFormatProvider); final allCurrencies = [ ('USD', '\$'), ('EUR', '€'), @@ -381,7 +385,8 @@ class _BalanceCard extends ConsumerWidget { return Container( width: double.infinity, - padding: const EdgeInsets.all(24), + height: 140, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), decoration: BoxDecoration( gradient: LinearGradient( colors: [ @@ -426,7 +431,7 @@ class _BalanceCard extends ConsumerWidget { fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( - formatAmount(currencyInfo.symbol, balance), + formatAmount(currencyInfo.symbol, balance, fmt), style: TextStyle( fontSize: 36, // max font size fontWeight: FontWeight.w700, @@ -463,7 +468,7 @@ class _BalanceCard extends ConsumerWidget { fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( - formatAmount(c.$2, converted), + formatAmount(c.$2, converted, fmt), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -505,7 +510,7 @@ class _SummaryRow extends StatelessWidget { } } -class _SummaryCard extends StatelessWidget { +class _SummaryCard extends ConsumerWidget { final String label; final double amount; final Color color; @@ -519,7 +524,8 @@ class _SummaryCard extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -545,7 +551,7 @@ class _SummaryCard extends StatelessWidget { Text(label, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6))), const SizedBox(height: 2), Text( - formatAmount(currencyInfo.symbol, amount), + formatAmount(currencyInfo.symbol, amount, fmt), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: color, fontWeight: FontWeight.w600, @@ -561,7 +567,7 @@ class _SummaryCard extends StatelessWidget { } } -class _TransactionTile extends StatelessWidget { +class _TransactionTile extends ConsumerWidget { final Transaction transaction; const _TransactionTile({required this.transaction}); @@ -571,7 +577,8 @@ class _TransactionTile extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final fmt = ref.watch(amountFormatProvider); final isIncome = transaction.type == TransactionType.income; final color = isIncome ? AppColors.income : AppColors.expense; final catColor = AppCategories.colors[transaction.category] ?? AppColors.accent; @@ -627,7 +634,7 @@ class _TransactionTile extends StatelessWidget { ), ), Text( - '${isIncome ? '+' : '-'}${formatAmount(transaction.currency, transaction.amount)}', + '${isIncome ? '+' : '-'}${formatAmount(transaction.currency, transaction.amount, fmt)}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: color, fontWeight: FontWeight.w700, diff --git a/lib/features/settings/provider.dart b/lib/features/settings/provider.dart index 5be4487..ccf857f 100644 --- a/lib/features/settings/provider.dart +++ b/lib/features/settings/provider.dart @@ -4,8 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:intl/intl.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/constants.dart'; import '../../shared/services/exchange_rate_service.dart'; import '../../shared/utils/currency_utils.dart'; +import '../../shared/providers/amount_format_provider.dart'; import '../dashboard/provider.dart'; final budgetProvider = StateNotifierProvider((ref) { @@ -115,6 +117,7 @@ class ExportService { Future exportToCSV() async { final transactions = _ref.read(transactionsProvider); final currency = _ref.read(currencyProvider); + final fmt = _ref.read(amountFormatProvider); // CSV header final buffer = StringBuffer(); @@ -125,7 +128,7 @@ class ExportService { final date = DateFormat('yyyy-MM-dd').format(tx.date); final type = tx.type.name; final category = tx.category; - final amount = formatAmount(tx.currency, tx.amount); + final amount = formatAmount(tx.currency, tx.amount, fmt); final note = tx.note?.replaceAll(',', ';') ?? ''; buffer.writeln('$date,$type,$category,$amount,${tx.currencyCode},$note'); } diff --git a/lib/features/settings/screen.dart b/lib/features/settings/screen.dart index 43d28d8..e34f9f2 100644 --- a/lib/features/settings/screen.dart +++ b/lib/features/settings/screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../core/constants.dart'; import '../../shared/utils/currency_utils.dart'; +import '../../shared/providers/amount_format_provider.dart'; import 'provider.dart'; class SettingsScreen extends ConsumerStatefulWidget { @@ -55,6 +56,7 @@ class _SettingsScreenState extends ConsumerState { final themeMode = ref.watch(themeProvider); final isDarkMode = themeMode == ThemeMode.dark; final currencyInfo = ref.watch(currencyProvider); + final fmt = ref.watch(amountFormatProvider); final isDark = Theme.of(context).brightness == Brightness.dark; // Update currency format when it changes @@ -235,7 +237,7 @@ class _SettingsScreenState extends ConsumerState { children: [ Text( budget != null - ? formatAmount(currencyInfo.symbol, budget) + ? formatAmount(currencyInfo.symbol, budget, fmt) : 'Not set', style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: budget != null ? AppColors.accent : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), @@ -258,6 +260,89 @@ class _SettingsScreenState extends ConsumerState { ), const SizedBox(height: 16), + // Amount Format Selector + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: isDark ? null : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.accent.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.format_list_numbered_rounded, + color: AppColors.accent, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Amount Format', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + ...AmountFormat.values.map((format) { + final isSelected = fmt == format; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GestureDetector( + onTap: () => ref.read(amountFormatProvider.notifier).set(format), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? AppColors.accent.withOpacity(0.2) + : Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(12), + border: isSelected + ? Border.all(color: AppColors.accent, width: 1.5) + : (isDark ? null : Border.all(color: const Color(0xFFDDDDEE), width: 1)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + format.label, + style: TextStyle( + color: isSelected ? AppColors.accent : Theme.of(context).colorScheme.onSurface, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + Text( + format.example.replaceFirst('SYM', currencyInfo.symbol), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ], + ), + ), + const SizedBox(height: 16), + // Currency Selector Container( padding: const EdgeInsets.all(20), diff --git a/lib/shared/providers/amount_format_provider.dart b/lib/shared/providers/amount_format_provider.dart new file mode 100644 index 0000000..ea213b7 --- /dev/null +++ b/lib/shared/providers/amount_format_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/constants.dart'; + +class AmountFormatNotifier extends StateNotifier { + AmountFormatNotifier() : super(AmountFormat.commasDot) { + _load(); + } + + void _load() async { + final prefs = await SharedPreferences.getInstance(); + final index = prefs.getInt('amount_format') ?? 0; + state = AmountFormat.values[index]; + } + + void set(AmountFormat format) async { + final prefs = await SharedPreferences.getInstance(); + state = format; + await prefs.setInt('amount_format', format.index); + } +} + +final amountFormatProvider = StateNotifierProvider( + (ref) => AmountFormatNotifier(), +); diff --git a/lib/shared/utils/currency_utils.dart b/lib/shared/utils/currency_utils.dart index e313f91..5166364 100644 --- a/lib/shared/utils/currency_utils.dart +++ b/lib/shared/utils/currency_utils.dart @@ -1,9 +1,9 @@ -String formatAmount(String symbol, double amount) { +import '../../core/constants.dart'; + +String formatAmount(String symbol, double amount, AmountFormat fmt) { // Symbols that need a space after them (prefix symbols like Br, ₽ etc.) const spaceAfter = {'Br', '₽'}; - final formatted = amount.toStringAsFixed(2); - if (spaceAfter.contains(symbol)) { - return '$symbol $formatted'; - } - return '$symbol$formatted'; + final formatted = fmt.format(amount); + final sep = spaceAfter.contains(symbol) ? ' ' : ''; + return '$symbol$sep$formatted'; }