diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/app/app.dart b/lib/app/app.dart index a764dd2..0c7e46c 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,16 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'router.dart'; import 'theme.dart'; +import '../features/settings/provider.dart'; -class App extends StatelessWidget { +class App extends ConsumerWidget { const App({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeProvider); + + // Trigger exchange rate fetch on app start + ref.watch(ratesInitProvider); + return MaterialApp.router( title: 'Finance Tracker', debugShowCheckedModeBanner: false, - theme: buildAppTheme(), + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, routerConfig: appRouter, ); } diff --git a/lib/app/router.dart b/lib/app/router.dart index ff1ce26..982f693 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -29,7 +29,7 @@ final appRouter = GoRouter( ), GoRoute( path: '/settings', - pageBuilder: (context, state) => const NoTransitionPage( + pageBuilder: (context, state) => NoTransitionPage( child: SettingsScreen(), ), ), @@ -61,12 +61,6 @@ class AppShell extends StatelessWidget { final idx = _locationToIndex(context); return Scaffold( body: child, - floatingActionButton: FloatingActionButton( - onPressed: () => context.push('/add'), - backgroundColor: const Color(0xFF7C6DED), - child: const Icon(Icons.add, color: Colors.white), - ), - floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, bottomNavigationBar: NavigationBar( selectedIndex: idx, onDestinationSelected: (i) { diff --git a/lib/app/theme.dart b/lib/app/theme.dart index 1decae2..ca5eb03 100644 --- a/lib/app/theme.dart +++ b/lib/app/theme.dart @@ -2,100 +2,203 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import '../core/constants.dart'; -ThemeData buildAppTheme() { - final base = ThemeData.dark(useMaterial3: true); - final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply( - bodyColor: AppColors.textPrimary, - displayColor: AppColors.textPrimary, - ); +class AppTheme { + static ThemeData get darkTheme { + final base = ThemeData.dark(useMaterial3: true); + final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply( + bodyColor: AppColors.textPrimary, + displayColor: AppColors.textPrimary, + ); - return base.copyWith( - textTheme: textTheme, - scaffoldBackgroundColor: AppColors.background, - colorScheme: const ColorScheme.dark( - surface: AppColors.surface, - primary: AppColors.accent, - secondary: AppColors.accent, - onPrimary: Colors.white, - onSurface: AppColors.textPrimary, - ), - cardTheme: CardThemeData( - color: AppColors.surface, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + return base.copyWith( + textTheme: textTheme, + scaffoldBackgroundColor: AppColors.background, + colorScheme: const ColorScheme.dark( + surface: AppColors.surface, + primary: AppColors.accent, + secondary: AppColors.accent, + onPrimary: Colors.white, + onSurface: AppColors.textPrimary, ), - margin: EdgeInsets.zero, - ), - appBarTheme: AppBarTheme( - backgroundColor: AppColors.background, - elevation: 0, - centerTitle: false, - titleTextStyle: GoogleFonts.poppins( - color: AppColors.textPrimary, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - iconTheme: const IconThemeData(color: AppColors.textPrimary), - ), - navigationBarTheme: NavigationBarThemeData( - backgroundColor: AppColors.surface, - indicatorColor: AppColors.accent.withOpacity(0.2), - labelTextStyle: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return GoogleFonts.poppins( - color: AppColors.accent, - fontSize: 12, - fontWeight: FontWeight.w600, - ); - } - return GoogleFonts.poppins( - color: AppColors.textSecondary, - fontSize: 12, - ); - }), - iconTheme: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return const IconThemeData(color: AppColors.accent); - } - return const IconThemeData(color: AppColors.textSecondary); - }), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: AppColors.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.divider), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.divider), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.accent, width: 1.5), - ), - labelStyle: const TextStyle(color: AppColors.textSecondary), - hintStyle: const TextStyle(color: AppColors.textSecondary), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.accent, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 52), + cardTheme: CardThemeData( + color: AppColors.surface, + elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(16), ), - textStyle: GoogleFonts.poppins( - fontSize: 16, + margin: EdgeInsets.zero, + ), + appBarTheme: AppBarTheme( + backgroundColor: AppColors.background, + elevation: 0, + centerTitle: false, + titleTextStyle: GoogleFonts.poppins( + color: AppColors.textPrimary, + fontSize: 20, fontWeight: FontWeight.w600, ), + iconTheme: const IconThemeData(color: AppColors.textPrimary), ), - ), - dividerTheme: const DividerThemeData( - color: AppColors.divider, - thickness: 1, - ), - ); + navigationBarTheme: NavigationBarThemeData( + backgroundColor: AppColors.surface, + indicatorColor: AppColors.accent.withOpacity(0.2), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return GoogleFonts.poppins( + color: AppColors.accent, + fontSize: 12, + fontWeight: FontWeight.w600, + ); + } + return GoogleFonts.poppins( + color: AppColors.textSecondary, + fontSize: 12, + ); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const IconThemeData(color: AppColors.accent); + } + return const IconThemeData(color: AppColors.textSecondary); + }), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.divider), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.divider), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.accent, width: 1.5), + ), + labelStyle: const TextStyle(color: AppColors.textSecondary), + hintStyle: const TextStyle(color: AppColors.textSecondary), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accent, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + textStyle: GoogleFonts.poppins( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + dividerTheme: const DividerThemeData( + color: AppColors.divider, + thickness: 1, + ), + ); + } + + static ThemeData get lightTheme { + final base = ThemeData.light(useMaterial3: true); + final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply( + bodyColor: const Color(0xFF1A1A24), + displayColor: const Color(0xFF1A1A24), + ); + + return base.copyWith( + textTheme: textTheme, + scaffoldBackgroundColor: const Color(0xFFF5F5F5), + colorScheme: const ColorScheme.light( + surface: Colors.white, + primary: AppColors.accent, + secondary: AppColors.accent, + onPrimary: Colors.white, + onSurface: Color(0xFF1A1A24), + ), + cardTheme: CardThemeData( + color: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + margin: EdgeInsets.zero, + ), + appBarTheme: AppBarTheme( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0, + centerTitle: false, + titleTextStyle: GoogleFonts.poppins( + color: const Color(0xFF1A1A24), + fontSize: 20, + fontWeight: FontWeight.w600, + ), + iconTheme: const IconThemeData(color: Color(0xFF1A1A24)), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: Colors.white, + indicatorColor: AppColors.accent.withOpacity(0.2), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return GoogleFonts.poppins( + color: AppColors.accent, + fontSize: 12, + fontWeight: FontWeight.w600, + ); + } + return GoogleFonts.poppins( + color: const Color(0xFF666666), + fontSize: 12, + ); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const IconThemeData(color: AppColors.accent); + } + return const IconThemeData(color: Color(0xFF666666)); + }), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE0E0E0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.accent, width: 1.5), + ), + labelStyle: const TextStyle(color: Color(0xFF666666)), + hintStyle: const TextStyle(color: Color(0xFF999999)), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accent, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + textStyle: GoogleFonts.poppins( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + dividerTheme: const DividerThemeData( + color: Color(0xFFE0E0E0), + thickness: 1, + ), + ); + } } + +// Keep for backward compatibility +ThemeData buildAppTheme() => AppTheme.darkTheme; diff --git a/lib/core/constants.dart b/lib/core/constants.dart index f3a84c0..5c13953 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -11,6 +11,13 @@ class AppColors { static const textSecondary = Color(0xFF8888A0); static const divider = Color(0xFF2A2A38); static const warning = Color(0xFFFFB74D); + + // Light theme colors + static const lightBackground = Color(0xFFF5F5F7); + static const lightSurface = Color(0xFFFFFFFF); + static const lightTextPrimary = Color(0xFF1A1A24); + static const lightTextSecondary = Color(0xFF6B6B80); + static const lightDivider = Color(0xFFE0E0E8); } class AppCategories { @@ -66,3 +73,33 @@ class AppCategories { 'Other': Color(0xFFB469FF), }; } + +class CurrencyOption { + final String symbol; + final String name; + final String code; + + const CurrencyOption({ + required this.symbol, + required this.name, + required this.code, + }); +} + +class AppCurrencies { + static const options = [ + CurrencyOption(symbol: '\$', name: 'US Dollar', code: 'USD'), + CurrencyOption(symbol: '€', name: 'Euro', code: 'EUR'), + CurrencyOption(symbol: '£', name: 'British Pound', code: 'GBP'), + CurrencyOption(symbol: 'Br', name: 'Belarusian Ruble', code: 'BYN'), + CurrencyOption(symbol: '₽', name: 'Russian Ruble', code: 'RUB'), + CurrencyOption(symbol: '₴', name: 'Ukrainian Hryvnia', code: 'UAH'), + ]; + + static CurrencyOption findBySymbol(String symbol) { + return options.firstWhere( + (c) => c.symbol == symbol, + orElse: () => options.first, + ); + } +} diff --git a/lib/features/add_transaction/provider.dart b/lib/features/add_transaction/provider.dart index 24a6551..99272f5 100644 --- a/lib/features/add_transaction/provider.dart +++ b/lib/features/add_transaction/provider.dart @@ -21,6 +21,21 @@ class AddTransactionState { this.editingId, }); + factory AddTransactionState.fromTransaction(Transaction tx) { + return AddTransactionState( + amount: tx.amount, + category: tx.category, + type: tx.type, + date: tx.date, + note: tx.note ?? '', + editingId: tx.id, + ); + } + + factory AddTransactionState.empty() { + return AddTransactionState(date: DateTime.now()); + } + AddTransactionState copyWith({ double? amount, String? category, @@ -44,8 +59,10 @@ class AddTransactionState { } class AddTransactionNotifier extends StateNotifier { - AddTransactionNotifier() - : super(AddTransactionState(date: DateTime.now())); + AddTransactionNotifier(Transaction? initial) + : super(initial != null + ? AddTransactionState.fromTransaction(initial) + : AddTransactionState.empty()); void setAmount(double? v) => state = state.copyWith(amount: v); @@ -63,27 +80,17 @@ class AddTransactionNotifier extends StateNotifier { void setSubmitting(bool v) => state = state.copyWith(isSubmitting: v); - void initializeForEdit(Transaction transaction) { - state = AddTransactionState( - amount: transaction.amount, - category: transaction.category, - type: transaction.type, - date: transaction.date, - note: transaction.note ?? '', - editingId: transaction.id, - ); - } - - void reset() => state = AddTransactionState(date: DateTime.now()); + void reset() => state = AddTransactionState.empty(); } -final addTransactionProvider = - StateNotifierProvider.autoDispose( - (ref) => AddTransactionNotifier(), +final addTransactionProvider = StateNotifierProvider.autoDispose + .family( + (ref, initial) => AddTransactionNotifier(initial), ); // Reactive categories based on selected type -final availableCategoriesProvider = Provider.autoDispose>((ref) { - final type = ref.watch(addTransactionProvider.select((s) => s.type)); +final availableCategoriesProvider = + Provider.autoDispose.family, Transaction?>((ref, initial) { + final type = ref.watch(addTransactionProvider(initial).select((s) => s.type)); return AppCategories.forType(type); }); diff --git a/lib/features/add_transaction/screen.dart b/lib/features/add_transaction/screen.dart index 97427c1..1cd1bbf 100644 --- a/lib/features/add_transaction/screen.dart +++ b/lib/features/add_transaction/screen.dart @@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart'; import '../../core/constants.dart'; import '../../shared/models/transaction.dart'; import '../dashboard/provider.dart'; +import '../settings/provider.dart'; import 'provider.dart'; const _uuid = Uuid(); @@ -30,11 +31,8 @@ class _AddTransactionScreenState extends ConsumerState { void initState() { super.initState(); if (widget.initial != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(addTransactionProvider.notifier).initializeForEdit(widget.initial!); - _amountController.text = widget.initial!.amount.toString(); - _noteController.text = widget.initial!.note ?? ''; - }); + _amountController.text = widget.initial!.amount.toString(); + _noteController.text = widget.initial!.note ?? ''; } } @@ -47,8 +45,9 @@ class _AddTransactionScreenState extends ConsumerState { Future _submit() async { if (!_formKey.currentState!.validate()) return; - final state = ref.read(addTransactionProvider); - ref.read(addTransactionProvider.notifier).setSubmitting(true); + final state = ref.read(addTransactionProvider(widget.initial)); + final currencyInfo = ref.read(currencyProvider); + ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(true); final tx = Transaction( id: state.editingId ?? _uuid.v4(), @@ -57,6 +56,8 @@ class _AddTransactionScreenState extends ConsumerState { type: state.type, date: state.date, note: state.note.isEmpty ? null : state.note, + currency: currencyInfo.symbol, + currencyCode: currencyInfo.code, ); if (state.isEditing) { @@ -65,13 +66,13 @@ class _AddTransactionScreenState extends ConsumerState { await ref.read(transactionsProvider.notifier).add(tx); } - ref.read(addTransactionProvider.notifier).setSubmitting(false); + ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(false); if (mounted) context.pop(); } Future _pickDate() async { - final state = ref.read(addTransactionProvider); + final state = ref.read(addTransactionProvider(widget.initial)); final picked = await showDatePicker( context: context, initialDate: state.date, @@ -88,14 +89,15 @@ class _AddTransactionScreenState extends ConsumerState { ), ); if (picked != null) { - ref.read(addTransactionProvider.notifier).setDate(picked); + ref.read(addTransactionProvider(widget.initial).notifier).setDate(picked); } } @override Widget build(BuildContext context) { - final state = ref.watch(addTransactionProvider); - final categories = ref.watch(availableCategoriesProvider); + final state = ref.watch(addTransactionProvider(widget.initial)); + final categories = ref.watch(availableCategoriesProvider(widget.initial)); + final currencyInfo = ref.watch(currencyProvider); return Scaffold( backgroundColor: AppColors.background, @@ -116,7 +118,7 @@ class _AddTransactionScreenState extends ConsumerState { _TypeToggle( selected: state.type, onChanged: (t) => - ref.read(addTransactionProvider.notifier).setType(t), + ref.read(addTransactionProvider(widget.initial).notifier).setType(t), ), const SizedBox(height: 24), @@ -133,10 +135,10 @@ class _AddTransactionScreenState extends ConsumerState { color: AppColors.textPrimary, fontWeight: FontWeight.w600, ), - decoration: const InputDecoration( - prefixText: '\$ ', - prefixStyle: TextStyle( - color: AppColors.textSecondary, + decoration: InputDecoration( + prefixText: '${currencyInfo.symbol} ', + prefixStyle: const TextStyle( + color: AppColors.textPrimary, fontSize: 20, fontWeight: FontWeight.w600, ), @@ -144,7 +146,7 @@ class _AddTransactionScreenState extends ConsumerState { ), onChanged: (v) { final parsed = double.tryParse(v); - ref.read(addTransactionProvider.notifier).setAmount(parsed); + ref.read(addTransactionProvider(widget.initial).notifier).setAmount(parsed); }, validator: (v) { if (v == null || v.isEmpty) return 'Enter an amount'; @@ -162,7 +164,7 @@ class _AddTransactionScreenState extends ConsumerState { categories: categories, selected: state.category, onChanged: (c) => - ref.read(addTransactionProvider.notifier).setCategory(c), + ref.read(addTransactionProvider(widget.initial).notifier).setCategory(c), ), const SizedBox(height: 20), @@ -206,7 +208,7 @@ class _AddTransactionScreenState extends ConsumerState { hintText: 'Add a note...', ), onChanged: (v) => - ref.read(addTransactionProvider.notifier).setNote(v), + ref.read(addTransactionProvider(widget.initial).notifier).setNote(v), ), const SizedBox(height: 32), diff --git a/lib/features/categories/screen.dart b/lib/features/categories/screen.dart index 23a0ee0..b819e2e 100644 --- a/lib/features/categories/screen.dart +++ b/lib/features/categories/screen.dart @@ -1,12 +1,12 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../core/constants.dart'; +import '../settings/provider.dart'; import 'provider.dart'; -final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); - enum ChartType { pie, bar } class CategoriesScreen extends ConsumerStatefulWidget { @@ -25,6 +25,7 @@ class _CategoriesScreenState extends ConsumerState { final data = ref.watch(categoryExpenseProvider); final monthlyData = ref.watch(monthlyBreakdownProvider); final total = data.values.fold(0.0, (a, b) => a + b); + final currencyInfo = ref.watch(currencyProvider); // Sort categories by amount descending final sortedEntries = data.entries.toList() @@ -32,42 +33,46 @@ class _CategoriesScreenState extends ConsumerState { return Scaffold( backgroundColor: AppColors.background, + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Categories', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'Expense breakdown', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + actions: [ + _ChartToggle( + selected: _chartType, + onChanged: (t) => setState(() => _chartType = t), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.add_circle_rounded), + iconSize: 32, + color: AppColors.accent, + onPressed: () => context.push('/add'), + tooltip: 'Add Transaction', + ), + ], + ), body: SafeArea( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Categories', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - Text( - 'Expense breakdown', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - _ChartToggle( - selected: _chartType, - onChanged: (t) => setState(() => _chartType = t), - ), - ], - ), - const SizedBox(height: 24), if (data.isEmpty) const Expanded(child: _EmptyState()) else @@ -80,9 +85,10 @@ class _CategoriesScreenState extends ConsumerState { total: total, touchedIndex: _touchedIndex, onTouch: (i) => setState(() => _touchedIndex = i), + currency: currencyInfo.symbol, ) else - _BarChartCard(monthlyData: monthlyData), + _BarChartCard(monthlyData: monthlyData, currency: currencyInfo.symbol), const SizedBox(height: 20), Text( 'Ranked by Amount', @@ -103,6 +109,7 @@ class _CategoriesScreenState extends ConsumerState { category: cat, amount: amount, total: total, + currency: currencyInfo.symbol, ), ); }), @@ -185,12 +192,14 @@ class _PieChartCard extends StatelessWidget { final double total; final int touchedIndex; final ValueChanged onTouch; + final String currency; const _PieChartCard({ required this.data, required this.total, required this.touchedIndex, required this.onTouch, + required this.currency, }); @override @@ -257,7 +266,7 @@ class _PieChartCard extends StatelessWidget { ), ), Text( - _currencyFmt.format(total), + '$currency${total.toStringAsFixed(2)}', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: AppColors.textPrimary, fontWeight: FontWeight.w700, @@ -276,7 +285,8 @@ class _PieChartCard extends StatelessWidget { class _BarChartCard extends StatelessWidget { final List monthlyData; - const _BarChartCard({required this.monthlyData}); + final String currency; + const _BarChartCard({required this.monthlyData, required this.currency}); @override Widget build(BuildContext context) { @@ -311,7 +321,7 @@ class _BarChartCard extends StatelessWidget { touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( - _currencyFmt.format(rod.toY), + '$currency${rod.toY.toStringAsFixed(2)}', const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, @@ -394,11 +404,13 @@ class _CategoryRow extends StatelessWidget { final String category; final double amount; final double total; + final String currency; const _CategoryRow({ required this.rank, required this.category, required this.amount, required this.total, + required this.currency, }); @override @@ -457,7 +469,7 @@ class _CategoryRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - _currencyFmt.format(amount), + '$currency${amount.toStringAsFixed(2)}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppColors.expense, fontWeight: FontWeight.w700, diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index e870793..2b0fd5b 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../shared/models/transaction.dart'; import '../../shared/services/storage_service.dart'; +import '../settings/provider.dart'; final sharedPreferencesProvider = Provider((ref) { throw UnimplementedError('Override in main'); @@ -51,37 +52,50 @@ enum TransactionFilter { all, income, expense } final transactionFilterProvider = StateProvider((ref) => TransactionFilter.all); -// Derived providers +// Converted balance providers (convert all transactions to selected currency) final totalBalanceProvider = Provider((ref) { final txs = ref.watch(transactionsProvider); + final exchangeService = ref.watch(exchangeRateServiceProvider); + final targetCurrency = ref.watch(currencyProvider).code; + return txs.fold(0.0, (sum, t) { - return t.type == TransactionType.income ? sum + t.amount : sum - t.amount; + final converted = exchangeService.convert(t.amount, t.currencyCode, targetCurrency); + return t.type == TransactionType.income ? sum + converted : sum - converted; }); }); final totalIncomeProvider = Provider((ref) { - return ref - .watch(transactionsProvider) - .where((t) => t.type == TransactionType.income) - .fold(0.0, (sum, t) => sum + t.amount); + final txs = ref.watch(transactionsProvider).where((t) => t.type == TransactionType.income); + final exchangeService = ref.watch(exchangeRateServiceProvider); + final targetCurrency = ref.watch(currencyProvider).code; + + return txs.fold(0.0, (sum, t) { + return sum + exchangeService.convert(t.amount, t.currencyCode, targetCurrency); + }); }); final totalExpenseProvider = Provider((ref) { - return ref - .watch(transactionsProvider) - .where((t) => t.type == TransactionType.expense) - .fold(0.0, (sum, t) => sum + t.amount); + final txs = ref.watch(transactionsProvider).where((t) => t.type == TransactionType.expense); + final exchangeService = ref.watch(exchangeRateServiceProvider); + final targetCurrency = ref.watch(currencyProvider).code; + + return txs.fold(0.0, (sum, t) { + return sum + exchangeService.convert(t.amount, t.currencyCode, targetCurrency); + }); }); final currentMonthExpenseProvider = Provider((ref) { final now = DateTime.now(); - return ref - .watch(transactionsProvider) - .where((t) => - t.type == TransactionType.expense && - t.date.year == now.year && - t.date.month == now.month) - .fold(0.0, (sum, t) => sum + t.amount); + final txs = ref.watch(transactionsProvider).where((t) => + t.type == TransactionType.expense && + t.date.year == now.year && + t.date.month == now.month); + final exchangeService = ref.watch(exchangeRateServiceProvider); + final targetCurrency = ref.watch(currencyProvider).code; + + return txs.fold(0.0, (sum, t) { + return sum + exchangeService.convert(t.amount, t.currencyCode, targetCurrency); + }); }); final filteredTransactionsProvider = Provider>((ref) { diff --git a/lib/features/dashboard/screen.dart b/lib/features/dashboard/screen.dart index d3d09df..4e60dc4 100644 --- a/lib/features/dashboard/screen.dart +++ b/lib/features/dashboard/screen.dart @@ -7,8 +7,6 @@ import '../../shared/models/transaction.dart'; import '../settings/provider.dart'; import 'provider.dart'; -final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); - class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @@ -34,44 +32,67 @@ class _DashboardScreenState extends ConsumerState { final budget = ref.watch(budgetProvider); final recent = ref.watch(recentTransactionsProvider); final filter = ref.watch(transactionFilterProvider); + final currencyInfo = ref.watch(currencyProvider); final budgetExceeded = budget != null && monthExpense > budget; return Scaffold( backgroundColor: AppColors.background, + appBar: AppBar( + title: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'My Finances', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + DateFormat('MMMM yyyy').format(DateTime.now()), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.add_circle_rounded), + iconSize: 32, + color: AppColors.accent, + onPressed: () => context.push('/add'), + tooltip: 'Add Transaction', + ), + ], + ), body: SafeArea( child: CustomScrollView( + cacheExtent: 500, slivers: [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'My Finances', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - Text( - DateFormat('MMMM yyyy').format(DateTime.now()), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 24), - _BalanceCard(balance: balance), + _BalanceCard(balance: balance, currencyInfo: currencyInfo), const SizedBox(height: 16), - _SummaryRow(income: income, expense: expense), + _SummaryRow(income: income, expense: expense, currencyInfo: currencyInfo), if (budget != null) ...[ const SizedBox(height: 16), - _BudgetProgress(spent: monthExpense, budget: budget), + _BudgetProgress(spent: monthExpense, budget: budget, currencyInfo: currencyInfo), ], if (budgetExceeded) ...[ const SizedBox(height: 12), - _BudgetWarning(spent: monthExpense, budget: budget), + _BudgetWarning(spent: monthExpense, budget: budget, currencyInfo: currencyInfo), ], const SizedBox(height: 24), _SearchBar(controller: _searchController, ref: ref), @@ -102,7 +123,9 @@ class _DashboardScreenState extends ConsumerState { delegate: SliverChildBuilderDelegate( (context, i) => Padding( padding: const EdgeInsets.only(bottom: 10), - child: _TransactionTile(transaction: recent[i], ref: ref), + child: _TransactionTile( + transaction: recent[i], + ), ), childCount: recent.length, ), @@ -220,7 +243,8 @@ class _FilterChip extends StatelessWidget { class _BudgetProgress extends StatelessWidget { final double spent; final double budget; - const _BudgetProgress({required this.spent, required this.budget}); + final CurrencyInfo currencyInfo; + const _BudgetProgress({required this.spent, required this.budget, required this.currencyInfo}); @override Widget build(BuildContext context) { @@ -274,13 +298,13 @@ class _BudgetProgress extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Spent: ${_currencyFmt.format(spent)}', + 'Spent: ${currencyInfo.symbol}${spent.toStringAsFixed(2)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.textSecondary, ), ), Text( - 'Limit: ${_currencyFmt.format(budget)}', + 'Limit: ${currencyInfo.symbol}${budget.toStringAsFixed(2)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.textSecondary, ), @@ -296,7 +320,8 @@ class _BudgetProgress extends StatelessWidget { class _BudgetWarning extends StatelessWidget { final double spent; final double budget; - const _BudgetWarning({required this.spent, required this.budget}); + final CurrencyInfo currencyInfo; + const _BudgetWarning({required this.spent, required this.budget, required this.currencyInfo}); @override Widget build(BuildContext context) { @@ -314,7 +339,7 @@ class _BudgetWarning extends StatelessWidget { const SizedBox(width: 10), Expanded( child: Text( - 'Budget exceeded by ${_currencyFmt.format(over)}', + 'Budget exceeded by ${currencyInfo.symbol}${over.toStringAsFixed(2)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.expense, fontWeight: FontWeight.w600, @@ -329,7 +354,8 @@ class _BudgetWarning extends StatelessWidget { class _BalanceCard extends StatelessWidget { final double balance; - const _BalanceCard({required this.balance}); + final CurrencyInfo currencyInfo; + const _BalanceCard({required this.balance, required this.currencyInfo}); @override Widget build(BuildContext context) { @@ -362,13 +388,21 @@ class _BalanceCard extends StatelessWidget { ), const SizedBox(height: 8), Text( - _currencyFmt.format(balance), + '${currencyInfo.symbol}${balance.toStringAsFixed(2)}', style: Theme.of(context).textTheme.headlineMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w700, letterSpacing: -0.5, ), ), + const SizedBox(height: 4), + Text( + 'Converted to ${currencyInfo.symbol}${currencyInfo.code}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white60, + fontSize: 11, + ), + ), ], ), ); @@ -378,15 +412,16 @@ class _BalanceCard extends StatelessWidget { class _SummaryRow extends StatelessWidget { final double income; final double expense; - const _SummaryRow({required this.income, required this.expense}); + final CurrencyInfo currencyInfo; + const _SummaryRow({required this.income, required this.expense, required this.currencyInfo}); @override Widget build(BuildContext context) { return Row( children: [ - Expanded(child: _SummaryCard(label: 'Income', amount: income, color: AppColors.income, icon: Icons.arrow_downward_rounded)), + Expanded(child: _SummaryCard(label: 'Income', amount: income, color: AppColors.income, icon: Icons.arrow_downward_rounded, currencyInfo: currencyInfo)), const SizedBox(width: 12), - Expanded(child: _SummaryCard(label: 'Expenses', amount: expense, color: AppColors.expense, icon: Icons.arrow_upward_rounded)), + Expanded(child: _SummaryCard(label: 'Expenses', amount: expense, color: AppColors.expense, icon: Icons.arrow_upward_rounded, currencyInfo: currencyInfo)), ], ); } @@ -397,7 +432,8 @@ class _SummaryCard extends StatelessWidget { final double amount; final Color color; final IconData icon; - const _SummaryCard({required this.label, required this.amount, required this.color, required this.icon}); + final CurrencyInfo currencyInfo; + const _SummaryCard({required this.label, required this.amount, required this.color, required this.icon, required this.currencyInfo}); @override Widget build(BuildContext context) { @@ -426,7 +462,7 @@ class _SummaryCard extends StatelessWidget { Text(label, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: AppColors.textSecondary)), const SizedBox(height: 2), Text( - _currencyFmt.format(amount), + '${currencyInfo.symbol}${amount.toStringAsFixed(2)}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: color, fontWeight: FontWeight.w600, @@ -442,12 +478,11 @@ class _SummaryCard extends StatelessWidget { } } -class _TransactionTile extends StatelessWidget { +class _TransactionTile extends ConsumerWidget { final Transaction transaction; - final WidgetRef ref; - const _TransactionTile({required this.transaction, required this.ref}); + const _TransactionTile({required this.transaction}); - void _showUndoSnackBar(BuildContext context, Transaction tx) { + void _showUndoSnackBar(BuildContext context, WidgetRef ref, Transaction tx) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Transaction deleted'), @@ -464,7 +499,7 @@ class _TransactionTile extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isIncome = transaction.type == TransactionType.income; final color = isIncome ? AppColors.income : AppColors.expense; final catColor = AppCategories.colors[transaction.category] ?? AppColors.accent; @@ -484,7 +519,7 @@ class _TransactionTile extends StatelessWidget { ), onDismissed: (_) { ref.read(transactionsProvider.notifier).delete(transaction.id); - _showUndoSnackBar(context, transaction); + _showUndoSnackBar(context, ref, transaction); }, child: GestureDetector( onTap: () => context.push('/add', extra: transaction), @@ -536,7 +571,7 @@ class _TransactionTile extends StatelessWidget { ), ), Text( - '${isIncome ? '+' : '-'}${_currencyFmt.format(transaction.amount)}', + '${isIncome ? '+' : '-'}${transaction.currency}${transaction.amount.toStringAsFixed(2)}', 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 da6d6b3..7f6087a 100644 --- a/lib/features/settings/provider.dart +++ b/lib/features/settings/provider.dart @@ -1,4 +1,10 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; 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 '../../shared/services/exchange_rate_service.dart'; import '../dashboard/provider.dart'; final budgetProvider = StateNotifierProvider((ref) { @@ -16,3 +22,113 @@ class BudgetNotifier extends StateNotifier { state = budget; } } + +// Currency info: symbol and code +class CurrencyInfo { + final String symbol; + final String code; + const CurrencyInfo(this.symbol, this.code); +} + +const Map currencyMap = { + 'USD': CurrencyInfo('\$', 'USD'), + 'EUR': CurrencyInfo('€', 'EUR'), + 'BYN': CurrencyInfo('Br', 'BYN'), + 'RUB': CurrencyInfo('₽', 'RUB'), +}; + +class CurrencyNotifier extends StateNotifier { + CurrencyNotifier() : super(currencyMap['USD']!) { + _load(); + } + + void _load() async { + final prefs = await SharedPreferences.getInstance(); + final code = prefs.getString('currency_code') ?? 'USD'; + state = currencyMap[code] ?? currencyMap['USD']!; + } + + Future setCurrency(String code) async { + final prefs = await SharedPreferences.getInstance(); + state = currencyMap[code] ?? currencyMap['USD']!; + await prefs.setString('currency_code', code); + } +} + +final currencyProvider = StateNotifierProvider( + (ref) => CurrencyNotifier(), +); + +class ThemeModeNotifier extends StateNotifier { + ThemeModeNotifier() : super(ThemeMode.dark) { + _load(); + } + + void _load() async { + final prefs = await SharedPreferences.getInstance(); + state = (prefs.getBool('dark_mode') ?? true) ? ThemeMode.dark : ThemeMode.light; + } + + Future toggle() async { + final prefs = await SharedPreferences.getInstance(); + state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark; + await prefs.setBool('dark_mode', state == ThemeMode.dark); + } + + Future setThemeMode(bool isDark) async { + final prefs = await SharedPreferences.getInstance(); + state = isDark ? ThemeMode.dark : ThemeMode.light; + await prefs.setBool('dark_mode', isDark); + } +} + +final themeProvider = StateNotifierProvider( + (ref) => ThemeModeNotifier(), +); + +// Exchange rate service +final exchangeRateServiceProvider = Provider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return ExchangeRateService(prefs); +}); + +final ratesInitProvider = FutureProvider((ref) async { + await ref.read(exchangeRateServiceProvider).fetchRates(); +}); + +final exportProvider = Provider((ref) { + return ExportService(ref); +}); + +class ExportService { + final Ref _ref; + + ExportService(this._ref); + + Future exportToCSV() async { + final transactions = _ref.read(transactionsProvider); + final currency = _ref.read(currencyProvider); + + // CSV header + final buffer = StringBuffer(); + buffer.writeln('Date,Type,Category,Amount,Currency,Note'); + + // CSV rows + for (final tx in transactions) { + final date = DateFormat('yyyy-MM-dd').format(tx.date); + final type = tx.type.name; + final category = tx.category; + final amount = '${tx.currency}${tx.amount.toStringAsFixed(2)}'; + final note = tx.note?.replaceAll(',', ';') ?? ''; + buffer.writeln('$date,$type,$category,$amount,${tx.currencyCode},$note'); + } + + // Save to Downloads + final directory = await getApplicationDocumentsDirectory(); + final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + final file = File('${directory.path}/transactions_$timestamp.csv'); + await file.writeAsString(buffer.toString()); + + return file.path; + } +} diff --git a/lib/features/settings/screen.dart b/lib/features/settings/screen.dart index 8466857..53c8796 100644 --- a/lib/features/settings/screen.dart +++ b/lib/features/settings/screen.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../core/constants.dart'; import 'provider.dart'; -final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); - class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @@ -17,10 +16,13 @@ class SettingsScreen extends ConsumerStatefulWidget { class _SettingsScreenState extends ConsumerState { final _budgetController = TextEditingController(); bool _isEditing = false; + late NumberFormat _currencyFmt; @override void initState() { super.initState(); + final currencyInfo = ref.read(currencyProvider); + _currencyFmt = NumberFormat.currency(symbol: currencyInfo.symbol, decimalDigits: 2); final budget = ref.read(budgetProvider); if (budget != null) { _budgetController.text = budget.toStringAsFixed(2); @@ -49,27 +51,196 @@ class _SettingsScreenState extends ConsumerState { @override Widget build(BuildContext context) { final budget = ref.watch(budgetProvider); + final themeMode = ref.watch(themeProvider); + final isDarkMode = themeMode == ThemeMode.dark; + final currencyInfo = ref.watch(currencyProvider); + + // Update currency format when it changes + _currencyFmt = NumberFormat.currency(symbol: currencyInfo.symbol, decimalDigits: 2); return Scaffold( backgroundColor: AppColors.background, - body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Settings', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( + style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), Text( 'Manage your preferences', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.textSecondary, ), ), - const SizedBox(height: 32), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.add_circle_rounded), + iconSize: 32, + color: AppColors.accent, + onPressed: () => context.push('/add'), + tooltip: 'Add Transaction', + ), + ], + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + children: [ + + // Theme Toggle + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.divider), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.accent.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isDarkMode ? Icons.dark_mode_rounded : Icons.light_mode_rounded, + color: AppColors.accent, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dark Mode', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + Text( + isDarkMode ? 'Enabled' : 'Disabled', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Switch( + value: isDarkMode, + onChanged: (value) { + ref.read(themeProvider.notifier).setThemeMode(value); + }, + activeColor: AppColors.accent, + ), + ], + ), + ), + const SizedBox(height: 16), + + // Currency Selector + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.divider), + ), + 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.attach_money_rounded, + color: AppColors.accent, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Currency', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: ['USD', 'EUR', 'BYN', 'RUB'].map((code) { + final info = currencyMap[code]!; + final isSelected = currencyInfo.code == code; + return Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () { + ref.read(currencyProvider.notifier).setCurrency(code); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? AppColors.accent.withOpacity(0.2) + : AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.accent : AppColors.divider, + width: isSelected ? 1.5 : 1, + ), + ), + child: Column( + children: [ + Text( + info.symbol, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: isSelected ? AppColors.accent : AppColors.textSecondary, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.normal, + ), + ), + const SizedBox(height: 2), + Text( + code, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isSelected ? AppColors.accent : AppColors.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Budget Setting Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -123,8 +294,8 @@ class _SettingsScreenState extends ConsumerState { inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')), ], - decoration: const InputDecoration( - prefixText: '\$ ', + decoration: InputDecoration( + prefixText: '${currencyInfo.symbol} ', hintText: '0.00', helperText: 'Leave empty to remove budget limit', ), diff --git a/lib/shared/models/transaction.dart b/lib/shared/models/transaction.dart index 400b99a..aae4b01 100644 --- a/lib/shared/models/transaction.dart +++ b/lib/shared/models/transaction.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart'; enum TransactionType { income, expense } +enum RecurrenceType { none, daily, weekly, monthly } + @immutable class Transaction { final String id; @@ -10,6 +12,10 @@ class Transaction { final TransactionType type; final DateTime date; final String? note; + final RecurrenceType recurrence; + final DateTime? lastOccurrence; + final String currency; + final String currencyCode; const Transaction({ required this.id, @@ -18,6 +24,10 @@ class Transaction { required this.type, required this.date, this.note, + this.recurrence = RecurrenceType.none, + this.lastOccurrence, + this.currency = '\$', + this.currencyCode = 'USD', }); factory Transaction.fromJson(Map json) => Transaction( @@ -29,6 +39,17 @@ class Transaction { ), date: DateTime.parse(json['date'] as String), note: json['note'] as String?, + recurrence: json['recurrence'] != null + ? RecurrenceType.values.firstWhere( + (e) => e.name == json['recurrence'], + orElse: () => RecurrenceType.none, + ) + : RecurrenceType.none, + lastOccurrence: json['lastOccurrence'] != null + ? DateTime.parse(json['lastOccurrence'] as String) + : null, + currency: json['currency'] as String? ?? '\$', + currencyCode: json['currencyCode'] as String? ?? 'USD', ); Map toJson() => { @@ -38,6 +59,10 @@ class Transaction { 'type': type.name, 'date': date.toIso8601String(), 'note': note, + 'recurrence': recurrence.name, + 'lastOccurrence': lastOccurrence?.toIso8601String(), + 'currency': currency, + 'currencyCode': currencyCode, }; Transaction copyWith({ @@ -47,6 +72,10 @@ class Transaction { TransactionType? type, DateTime? date, String? note, + RecurrenceType? recurrence, + DateTime? lastOccurrence, + String? currency, + String? currencyCode, }) => Transaction( id: id ?? this.id, @@ -55,5 +84,9 @@ class Transaction { type: type ?? this.type, date: date ?? this.date, note: note ?? this.note, + recurrence: recurrence ?? this.recurrence, + lastOccurrence: lastOccurrence ?? this.lastOccurrence, + currency: currency ?? this.currency, + currencyCode: currencyCode ?? this.currencyCode, ); } diff --git a/lib/shared/services/exchange_rate_service.dart b/lib/shared/services/exchange_rate_service.dart new file mode 100644 index 0000000..477dbf9 --- /dev/null +++ b/lib/shared/services/exchange_rate_service.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class ExchangeRateService { + static const String _primaryUrl = 'https://open.er-api.com/v6/latest/USD'; + static const String _fallbackUrl = + 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json'; + static const String _cacheKey = 'exchange_rates'; + + static const Map _fallbackRates = { + 'USD': 1.0, + 'EUR': 0.92, + 'BYN': 3.25, + 'RUB': 90.0, + }; + + final SharedPreferences _prefs; + Map _rates = {}; + + ExchangeRateService(this._prefs) { + _loadCachedRates(); + } + + Map get currentRates => _rates.isEmpty ? _fallbackRates : _rates; + + void _loadCachedRates() { + final cached = _prefs.getString(_cacheKey); + if (cached != null) { + try { + final decoded = jsonDecode(cached) as Map; + _rates = decoded.map((k, v) => MapEntry(k, (v as num).toDouble())); + } catch (e) { + _rates = Map.from(_fallbackRates); + } + } else { + _rates = Map.from(_fallbackRates); + } + } + + Future fetchRates() async { + try { + // Try primary URL + final response = await http + .get(Uri.parse(_primaryUrl)) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['rates'] != null) { + final rates = data['rates'] as Map; + _rates = { + 'USD': 1.0, + 'EUR': (rates['EUR'] as num?)?.toDouble() ?? _fallbackRates['EUR']!, + 'BYN': (rates['BYN'] as num?)?.toDouble() ?? _fallbackRates['BYN']!, + 'RUB': (rates['RUB'] as num?)?.toDouble() ?? _fallbackRates['RUB']!, + }; + await _cacheRates(); + return; + } + } + } catch (e) { + // Primary failed, try fallback + } + + try { + // Try fallback URL + final response = await http + .get(Uri.parse(_fallbackUrl)) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['usd'] != null) { + final rates = data['usd'] as Map; + _rates = { + 'USD': 1.0, + 'EUR': (rates['eur'] as num?)?.toDouble() ?? _fallbackRates['EUR']!, + 'BYN': (rates['byn'] as num?)?.toDouble() ?? _fallbackRates['BYN']!, + 'RUB': (rates['rub'] as num?)?.toDouble() ?? _fallbackRates['RUB']!, + }; + await _cacheRates(); + return; + } + } + } catch (e) { + // Both failed, use cached or fallback + } + + // If both failed and no cache, use fallback + if (_rates.isEmpty) { + _rates = Map.from(_fallbackRates); + } + } + + Future _cacheRates() async { + final encoded = jsonEncode(_rates); + await _prefs.setString(_cacheKey, encoded); + } + + double convert(double amount, String from, String to) { + if (from == to) return amount; + + final fromRate = currentRates[from] ?? 1.0; + final toRate = currentRates[to] ?? 1.0; + + // Convert to USD first, then to target currency + final amountInUsd = amount / fromRate; + return amountInUsd * toRate; + } +} diff --git a/lib/shared/services/storage_service.dart b/lib/shared/services/storage_service.dart index cfe9ed8..ae869be 100644 --- a/lib/shared/services/storage_service.dart +++ b/lib/shared/services/storage_service.dart @@ -1,10 +1,15 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; import '../models/transaction.dart'; +const _uuid = Uuid(); + class StorageService { static const _transactionsKey = 'transactions'; static const _budgetKey = 'monthly_budget'; + static const _currencyKey = 'currency_symbol'; + static const _themeKey = 'is_dark_mode'; final SharedPreferences _prefs; @@ -55,4 +60,86 @@ class StorageService { await _prefs.setDouble(_budgetKey, budget); } } + + String loadCurrency() { + return _prefs.getString(_currencyKey) ?? '\$'; + } + + Future saveCurrency(String symbol) async { + await _prefs.setString(_currencyKey, symbol); + } + + bool loadThemeMode() { + return _prefs.getBool(_themeKey) ?? true; // default dark + } + + Future saveThemeMode(bool isDark) async { + await _prefs.setBool(_themeKey, isDark); + } + + // Process recurring transactions + Future processRecurringTransactions() async { + final transactions = loadTransactions(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + bool hasChanges = false; + + for (final tx in transactions) { + if (tx.recurrence == RecurrenceType.none) continue; + + final lastOccurrence = tx.lastOccurrence ?? tx.date; + final lastDate = DateTime( + lastOccurrence.year, + lastOccurrence.month, + lastOccurrence.day, + ); + + bool shouldCreate = false; + + switch (tx.recurrence) { + case RecurrenceType.daily: + shouldCreate = today.isAfter(lastDate); + break; + case RecurrenceType.weekly: + final daysDiff = today.difference(lastDate).inDays; + shouldCreate = daysDiff >= 7; + break; + case RecurrenceType.monthly: + shouldCreate = (today.year > lastDate.year || + (today.year == lastDate.year && + today.month > lastDate.month)) && + today.day >= lastDate.day; + break; + case RecurrenceType.none: + break; + } + + if (shouldCreate) { + // Create new occurrence + final newTx = Transaction( + id: _uuid.v4(), + amount: tx.amount, + category: tx.category, + type: tx.type, + date: today, + note: tx.note, + recurrence: tx.recurrence, + lastOccurrence: today, + ); + transactions.add(newTx); + + // Update original transaction's lastOccurrence + final index = transactions.indexWhere((t) => t.id == tx.id); + if (index != -1) { + transactions[index] = tx.copyWith(lastOccurrence: today); + } + + hasChanges = true; + } + } + + if (hasChanges) { + await saveTransactions(transactions); + } + } } diff --git a/pubspec.lock b/pubspec.lock index e53bb16..fe344be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,7 +177,7 @@ packages: source: hosted version: "1.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -289,7 +289,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" diff --git a/pubspec.yaml b/pubspec.yaml index 4a61fb2..a05888a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: google_fonts: ^6.2.1 intl: ^0.19.0 uuid: ^4.5.1 + path_provider: ^2.1.5 + http: ^1.2.0 dev_dependencies: flutter_test: