From 99d985ca452d424a02f98323370b0c299184a332 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 20 Mar 2026 09:26:14 +0300 Subject: [PATCH] update --- lib/app/router.dart | 20 +- lib/core/constants.dart | 38 ++- lib/features/add_transaction/provider.dart | 36 +- lib/features/add_transaction/screen.dart | 40 ++- lib/features/categories/provider.dart | 26 ++ lib/features/categories/screen.dart | 318 ++++++++++++++--- lib/features/dashboard/provider.dart | 63 +++- lib/features/dashboard/screen.dart | 380 +++++++++++++++++---- lib/features/settings/provider.dart | 18 + lib/features/settings/screen.dart | 212 ++++++++++++ lib/shared/services/storage_service.dart | 28 +- 11 files changed, 1065 insertions(+), 114 deletions(-) create mode 100644 lib/features/settings/provider.dart create mode 100644 lib/features/settings/screen.dart diff --git a/lib/app/router.dart b/lib/app/router.dart index ad81be8..ff1ce26 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart'; import '../features/dashboard/screen.dart'; import '../features/add_transaction/screen.dart'; import '../features/categories/screen.dart'; +import '../features/settings/screen.dart'; +import '../shared/models/transaction.dart'; final _shellKey = GlobalKey(); @@ -25,11 +27,20 @@ final appRouter = GoRouter( child: CategoriesScreen(), ), ), + GoRoute( + path: '/settings', + pageBuilder: (context, state) => const NoTransitionPage( + child: SettingsScreen(), + ), + ), ], ), GoRoute( path: '/add', - builder: (context, state) => const AddTransactionScreen(), + builder: (context, state) { + final transaction = state.extra as Transaction?; + return AddTransactionScreen(initial: transaction); + }, ), ], ); @@ -41,6 +52,7 @@ class AppShell extends StatelessWidget { int _locationToIndex(BuildContext context) { final location = GoRouterState.of(context).uri.toString(); if (location.startsWith('/categories')) return 1; + if (location.startsWith('/settings')) return 2; return 0; } @@ -60,6 +72,7 @@ class AppShell extends StatelessWidget { onDestinationSelected: (i) { if (i == 0) context.go('/dashboard'); if (i == 1) context.go('/categories'); + if (i == 2) context.go('/settings'); }, destinations: const [ NavigationDestination( @@ -72,6 +85,11 @@ class AppShell extends StatelessWidget { selectedIcon: Icon(Icons.pie_chart_rounded), label: 'Categories', ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings_rounded), + label: 'Settings', + ), ], ), ); diff --git a/lib/core/constants.dart b/lib/core/constants.dart index aad0424..f3a84c0 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../shared/models/transaction.dart'; class AppColors { static const background = Color(0xFF0F0F14); @@ -9,16 +10,45 @@ class AppColors { static const textPrimary = Color(0xFFEEEEF5); static const textSecondary = Color(0xFF8888A0); static const divider = Color(0xFF2A2A38); + static const warning = Color(0xFFFFB74D); } class AppCategories { - static const all = ['Food', 'Transport', 'Shopping', 'Health', 'Other']; + static const expenseCategories = [ + 'Food', + 'Transport', + 'Shopping', + 'Health', + 'Entertainment', + 'Other' + ]; + + static const incomeCategories = [ + 'Salary', + 'Freelance', + 'Gift', + 'Investment', + 'Refund', + 'Other' + ]; + + static List forType(TransactionType type) { + return type == TransactionType.expense + ? expenseCategories + : incomeCategories; + } static const icons = { 'Food': Icons.restaurant_rounded, 'Transport': Icons.directions_car_rounded, 'Shopping': Icons.shopping_bag_rounded, 'Health': Icons.favorite_rounded, + 'Entertainment': Icons.movie_rounded, + 'Salary': Icons.work_rounded, + 'Freelance': Icons.laptop_rounded, + 'Gift': Icons.card_giftcard_rounded, + 'Investment': Icons.trending_up_rounded, + 'Refund': Icons.money_rounded, 'Other': Icons.category_rounded, }; @@ -27,6 +57,12 @@ class AppCategories { 'Transport': Color(0xFF69B4FF), 'Shopping': Color(0xFFFFD369), 'Health': Color(0xFF69FFB4), + 'Entertainment': Color(0xFFFF69B4), + 'Salary': Color(0xFF4CAF8C), + 'Freelance': Color(0xFF69FFB4), + 'Gift': Color(0xFFFFB469), + 'Investment': Color(0xFF69B4FF), + 'Refund': Color(0xFFB4FF69), 'Other': Color(0xFFB469FF), }; } diff --git a/lib/features/add_transaction/provider.dart b/lib/features/add_transaction/provider.dart index 9e634be..24a6551 100644 --- a/lib/features/add_transaction/provider.dart +++ b/lib/features/add_transaction/provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/constants.dart'; import '../../shared/models/transaction.dart'; class AddTransactionState { @@ -8,6 +9,7 @@ class AddTransactionState { final DateTime date; final String note; final bool isSubmitting; + final String? editingId; const AddTransactionState({ this.amount, @@ -16,6 +18,7 @@ class AddTransactionState { required this.date, this.note = '', this.isSubmitting = false, + this.editingId, }); AddTransactionState copyWith({ @@ -25,6 +28,7 @@ class AddTransactionState { DateTime? date, String? note, bool? isSubmitting, + String? editingId, }) => AddTransactionState( amount: amount ?? this.amount, @@ -33,7 +37,10 @@ class AddTransactionState { date: date ?? this.date, note: note ?? this.note, isSubmitting: isSubmitting ?? this.isSubmitting, + editingId: editingId ?? this.editingId, ); + + bool get isEditing => editingId != null; } class AddTransactionNotifier extends StateNotifier { @@ -41,11 +48,32 @@ class AddTransactionNotifier extends StateNotifier { : super(AddTransactionState(date: DateTime.now())); void setAmount(double? v) => state = state.copyWith(amount: v); + void setCategory(String v) => state = state.copyWith(category: v); - void setType(TransactionType v) => state = state.copyWith(type: v); + + void setType(TransactionType v) { + // Reset category to first item of new type + final newCategory = AppCategories.forType(v).first; + state = state.copyWith(type: v, category: newCategory); + } + void setDate(DateTime v) => state = state.copyWith(date: v); + void setNote(String v) => state = state.copyWith(note: v); + 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()); } @@ -53,3 +81,9 @@ final addTransactionProvider = StateNotifierProvider.autoDispose( (ref) => AddTransactionNotifier(), ); + +// Reactive categories based on selected type +final availableCategoriesProvider = Provider.autoDispose>((ref) { + final type = ref.watch(addTransactionProvider.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 39ebcb7..97427c1 100644 --- a/lib/features/add_transaction/screen.dart +++ b/lib/features/add_transaction/screen.dart @@ -12,7 +12,9 @@ import 'provider.dart'; const _uuid = Uuid(); class AddTransactionScreen extends ConsumerStatefulWidget { - const AddTransactionScreen({super.key}); + final Transaction? initial; + + const AddTransactionScreen({super.key, this.initial}); @override ConsumerState createState() => @@ -24,6 +26,18 @@ class _AddTransactionScreenState extends ConsumerState { final _amountController = TextEditingController(); final _noteController = TextEditingController(); + @override + 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 ?? ''; + }); + } + } + @override void dispose() { _amountController.dispose(); @@ -37,7 +51,7 @@ class _AddTransactionScreenState extends ConsumerState { ref.read(addTransactionProvider.notifier).setSubmitting(true); final tx = Transaction( - id: _uuid.v4(), + id: state.editingId ?? _uuid.v4(), amount: state.amount!, category: state.category, type: state.type, @@ -45,7 +59,12 @@ class _AddTransactionScreenState extends ConsumerState { note: state.note.isEmpty ? null : state.note, ); - await ref.read(transactionsProvider.notifier).add(tx); + if (state.isEditing) { + await ref.read(transactionsProvider.notifier).update(tx); + } else { + await ref.read(transactionsProvider.notifier).add(tx); + } + ref.read(addTransactionProvider.notifier).setSubmitting(false); if (mounted) context.pop(); @@ -76,11 +95,12 @@ class _AddTransactionScreenState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(addTransactionProvider); + final categories = ref.watch(availableCategoriesProvider); return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( - title: const Text('Add Transaction'), + title: Text(state.isEditing ? 'Edit Transaction' : 'Add Transaction'), leading: IconButton( icon: const Icon(Icons.close_rounded), onPressed: () => context.pop(), @@ -139,6 +159,7 @@ class _AddTransactionScreenState extends ConsumerState { _SectionLabel('Category'), const SizedBox(height: 8), _CategoryPicker( + categories: categories, selected: state.category, onChanged: (c) => ref.read(addTransactionProvider.notifier).setCategory(c), @@ -200,7 +221,7 @@ class _AddTransactionScreenState extends ConsumerState { color: Colors.white, ), ) - : const Text('Save Transaction'), + : Text(state.isEditing ? 'Update Transaction' : 'Save Transaction'), ), ], ), @@ -309,16 +330,21 @@ class _TypeOption extends StatelessWidget { } class _CategoryPicker extends StatelessWidget { + final List categories; final String selected; final ValueChanged onChanged; - const _CategoryPicker({required this.selected, required this.onChanged}); + const _CategoryPicker({ + required this.categories, + required this.selected, + required this.onChanged, + }); @override Widget build(BuildContext context) { return Wrap( spacing: 8, runSpacing: 8, - children: AppCategories.all.map((cat) { + children: categories.map((cat) { final isSelected = cat == selected; final color = AppCategories.colors[cat] ?? AppColors.accent; final icon = AppCategories.icons[cat] ?? Icons.category_rounded; diff --git a/lib/features/categories/provider.dart b/lib/features/categories/provider.dart index e87a99b..aa386f4 100644 --- a/lib/features/categories/provider.dart +++ b/lib/features/categories/provider.dart @@ -12,3 +12,29 @@ final categoryExpenseProvider = Provider>((ref) { } return map; }); + +// Monthly breakdown for last 6 months +final monthlyBreakdownProvider = Provider>((ref) { + final txs = ref.watch(transactionsProvider) + .where((t) => t.type == TransactionType.expense); + + final now = DateTime.now(); + final months = []; + + for (var i = 5; i >= 0; i--) { + final month = DateTime(now.year, now.month - i, 1); + final total = txs + .where((t) => t.date.year == month.year && t.date.month == month.month) + .fold(0.0, (sum, t) => sum + t.amount); + months.add(MonthlyData(month: month, amount: total)); + } + + return months; +}); + +class MonthlyData { + final DateTime month; + final double amount; + + MonthlyData({required this.month, required this.amount}); +} diff --git a/lib/features/categories/screen.dart b/lib/features/categories/screen.dart index 6ecd2ce..23a0ee0 100644 --- a/lib/features/categories/screen.dart +++ b/lib/features/categories/screen.dart @@ -7,6 +7,8 @@ import 'provider.dart'; final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); +enum ChartType { pie, bar } + class CategoriesScreen extends ConsumerStatefulWidget { const CategoriesScreen({super.key}); @@ -16,12 +18,18 @@ class CategoriesScreen extends ConsumerStatefulWidget { class _CategoriesScreenState extends ConsumerState { int _touchedIndex = -1; + ChartType _chartType = ChartType.pie; @override Widget build(BuildContext context) { final data = ref.watch(categoryExpenseProvider); + final monthlyData = ref.watch(monthlyBreakdownProvider); final total = data.values.fold(0.0, (a, b) => a + b); + // Sort categories by amount descending + final sortedEntries = data.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + return Scaffold( backgroundColor: AppColors.background, body: SafeArea( @@ -30,18 +38,34 @@ class _CategoriesScreenState extends ConsumerState { 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, + 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) @@ -50,21 +74,38 @@ class _CategoriesScreenState extends ConsumerState { Expanded( child: ListView( children: [ - _PieChartCard( - data: data, - total: total, - touchedIndex: _touchedIndex, - onTouch: (i) => setState(() => _touchedIndex = i), - ), + if (_chartType == ChartType.pie) + _PieChartCard( + data: data, + total: total, + touchedIndex: _touchedIndex, + onTouch: (i) => setState(() => _touchedIndex = i), + ) + else + _BarChartCard(monthlyData: monthlyData), const SizedBox(height: 20), - ...data.entries.map((e) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _CategoryRow( - category: e.key, - amount: e.value, - total: total, + Text( + 'Ranked by Amount', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), - )), + ), + const SizedBox(height: 12), + ...sortedEntries.asMap().entries.map((entry) { + final rank = entry.key + 1; + final cat = entry.value.key; + final amount = entry.value.value; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _CategoryRow( + rank: rank, + category: cat, + amount: amount, + total: total, + ), + ); + }), const SizedBox(height: 80), ], ), @@ -77,6 +118,68 @@ class _CategoriesScreenState extends ConsumerState { } } +class _ChartToggle extends StatelessWidget { + final ChartType selected; + final ValueChanged onChanged; + const _ChartToggle({required this.selected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.divider), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ToggleButton( + icon: Icons.pie_chart_rounded, + isSelected: selected == ChartType.pie, + onTap: () => onChanged(ChartType.pie), + ), + _ToggleButton( + icon: Icons.bar_chart_rounded, + isSelected: selected == ChartType.bar, + onTap: () => onChanged(ChartType.bar), + ), + ], + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final IconData icon; + final bool isSelected; + final VoidCallback onTap; + const _ToggleButton({ + required this.icon, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isSelected ? AppColors.accent.withOpacity(0.15) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + color: isSelected ? AppColors.accent : AppColors.textSecondary, + size: 20, + ), + ), + ); + } +} + class _PieChartCard extends StatelessWidget { final Map data; final double total; @@ -171,11 +274,128 @@ class _PieChartCard extends StatelessWidget { } } +class _BarChartCard extends StatelessWidget { + final List monthlyData; + const _BarChartCard({required this.monthlyData}); + + @override + Widget build(BuildContext context) { + final maxY = monthlyData.map((e) => e.amount).reduce((a, b) => a > b ? a : b); + final adjustedMaxY = maxY * 1.2; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.divider), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Last 6 Months', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: adjustedMaxY > 0 ? adjustedMaxY : 100, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + return BarTooltipItem( + _currencyFmt.format(rod.toY), + const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + if (value.toInt() >= 0 && value.toInt() < monthlyData.length) { + final month = monthlyData[value.toInt()].month; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + DateFormat('MMM').format(month), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + ), + ), + ); + } + return const Text(''); + }, + ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: adjustedMaxY > 0 ? adjustedMaxY / 4 : 25, + getDrawingHorizontalLine: (value) => FlLine( + color: AppColors.divider, + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + barGroups: List.generate( + monthlyData.length, + (i) => BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: monthlyData[i].amount, + color: AppColors.accent, + width: 24, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(6), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + class _CategoryRow extends StatelessWidget { + final int rank; final String category; final double amount; final double total; const _CategoryRow({ + required this.rank, required this.category, required this.amount, required this.total, @@ -198,6 +418,23 @@ class _CategoryRow extends StatelessWidget { children: [ Row( children: [ + Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: rank <= 3 ? color.withOpacity(0.2) : AppColors.divider, + shape: BoxShape.circle, + ), + child: Text( + '$rank', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: rank <= 3 ? color : AppColors.textSecondary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 10), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -216,12 +453,23 @@ class _CategoryRow extends StatelessWidget { ), ), ), - Text( - _currencyFmt.format(amount), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColors.expense, - fontWeight: FontWeight.w700, - ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _currencyFmt.format(amount), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.expense, + fontWeight: FontWeight.w700, + ), + ), + Text( + '${(pct * 100).toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], ), ], ), @@ -235,16 +483,6 @@ class _CategoryRow extends StatelessWidget { minHeight: 6, ), ), - const SizedBox(height: 4), - Align( - alignment: Alignment.centerRight, - child: Text( - '${(pct * 100).toStringAsFixed(1)}%', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.textSecondary, - ), - ), - ), ], ), ); diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index 89281eb..e870793 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -27,12 +27,30 @@ class TransactionsNotifier extends StateNotifier> { state = _storage.loadTransactions(); } + Future update(Transaction transaction) async { + await _storage.updateTransaction(transaction); + state = _storage.loadTransactions(); + } + Future delete(String id) async { await _storage.deleteTransaction(id); state = _storage.loadTransactions(); } + + void restore(Transaction transaction) { + state = [...state, transaction]; + _storage.addTransaction(transaction); + } } +// Search and filter state +final searchQueryProvider = StateProvider((ref) => ''); + +enum TransactionFilter { all, income, expense } + +final transactionFilterProvider = + StateProvider((ref) => TransactionFilter.all); + // Derived providers final totalBalanceProvider = Provider((ref) { final txs = ref.watch(transactionsProvider); @@ -55,8 +73,45 @@ final totalExpenseProvider = Provider((ref) { .fold(0.0, (sum, t) => sum + t.amount); }); -final recentTransactionsProvider = Provider>((ref) { - final txs = List.from(ref.watch(transactionsProvider)); - txs.sort((a, b) => b.date.compareTo(a.date)); - return txs.take(20).toList(); +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 filteredTransactionsProvider = Provider>((ref) { + final txs = ref.watch(transactionsProvider); + final query = ref.watch(searchQueryProvider).toLowerCase(); + final filter = ref.watch(transactionFilterProvider); + + var filtered = txs; + + // Apply type filter + if (filter == TransactionFilter.income) { + filtered = filtered.where((t) => t.type == TransactionType.income).toList(); + } else if (filter == TransactionFilter.expense) { + filtered = filtered.where((t) => t.type == TransactionType.expense).toList(); + } + + // Apply search query + if (query.isNotEmpty) { + filtered = filtered.where((t) { + final matchesCategory = t.category.toLowerCase().contains(query); + final matchesNote = t.note?.toLowerCase().contains(query) ?? false; + return matchesCategory || matchesNote; + }).toList(); + } + + // Sort by date descending + filtered.sort((a, b) => b.date.compareTo(a.date)); + return filtered; +}); + +final recentTransactionsProvider = Provider>((ref) { + return ref.watch(filteredTransactionsProvider).take(20).toList(); }); diff --git a/lib/features/dashboard/screen.dart b/lib/features/dashboard/screen.dart index 721b03d..d3d09df 100644 --- a/lib/features/dashboard/screen.dart +++ b/lib/features/dashboard/screen.dart @@ -1,21 +1,41 @@ 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 '../../shared/models/transaction.dart'; +import '../settings/provider.dart'; import 'provider.dart'; final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); -class DashboardScreen extends ConsumerWidget { +class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends ConsumerState { + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final balance = ref.watch(totalBalanceProvider); final income = ref.watch(totalIncomeProvider); final expense = ref.watch(totalExpenseProvider); + final monthExpense = ref.watch(currentMonthExpenseProvider); + final budget = ref.watch(budgetProvider); final recent = ref.watch(recentTransactionsProvider); + final filter = ref.watch(transactionFilterProvider); + + final budgetExceeded = budget != null && monthExpense > budget; return Scaffold( backgroundColor: AppColors.background, @@ -45,9 +65,21 @@ class DashboardScreen extends ConsumerWidget { _BalanceCard(balance: balance), const SizedBox(height: 16), _SummaryRow(income: income, expense: expense), - const SizedBox(height: 28), + if (budget != null) ...[ + const SizedBox(height: 16), + _BudgetProgress(spent: monthExpense, budget: budget), + ], + if (budgetExceeded) ...[ + const SizedBox(height: 12), + _BudgetWarning(spent: monthExpense, budget: budget), + ], + const SizedBox(height: 24), + _SearchBar(controller: _searchController, ref: ref), + const SizedBox(height: 12), + _FilterChips(selected: filter, ref: ref), + const SizedBox(height: 20), Text( - 'Recent Transactions', + 'Transactions', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -83,6 +115,218 @@ class DashboardScreen extends ConsumerWidget { } } +class _SearchBar extends StatelessWidget { + final TextEditingController controller; + final WidgetRef ref; + const _SearchBar({required this.controller, required this.ref}); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'Search transactions...', + prefixIcon: const Icon(Icons.search_rounded, color: AppColors.textSecondary), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded, size: 20), + color: AppColors.textSecondary, + onPressed: () { + controller.clear(); + ref.read(searchQueryProvider.notifier).state = ''; + }, + ) + : null, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + onChanged: (v) => ref.read(searchQueryProvider.notifier).state = v, + ); + } +} + +class _FilterChips extends StatelessWidget { + final TransactionFilter selected; + final WidgetRef ref; + const _FilterChips({required this.selected, required this.ref}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _FilterChip( + label: 'All', + isSelected: selected == TransactionFilter.all, + onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.all, + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Income', + isSelected: selected == TransactionFilter.income, + color: AppColors.income, + onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.income, + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Expense', + isSelected: selected == TransactionFilter.expense, + color: AppColors.expense, + onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.expense, + ), + ], + ); + } +} + +class _FilterChip extends StatelessWidget { + final String label; + final bool isSelected; + final Color? color; + final VoidCallback onTap; + const _FilterChip({ + required this.label, + required this.isSelected, + this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final chipColor = color ?? AppColors.accent; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? chipColor.withOpacity(0.2) : AppColors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? chipColor : AppColors.divider, + width: isSelected ? 1.5 : 1, + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isSelected ? chipColor : AppColors.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + } +} + +class _BudgetProgress extends StatelessWidget { + final double spent; + final double budget; + const _BudgetProgress({required this.spent, required this.budget}); + + @override + Widget build(BuildContext context) { + final ratio = spent / budget; + final color = ratio >= 1.0 + ? AppColors.expense + : ratio >= 0.8 + ? AppColors.warning + : AppColors.income; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.divider), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Monthly Budget', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + ), + Text( + '${(ratio * 100).toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: AppColors.divider, + valueColor: AlwaysStoppedAnimation(color), + minHeight: 8, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Spent: ${_currencyFmt.format(spent)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + Text( + 'Limit: ${_currencyFmt.format(budget)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _BudgetWarning extends StatelessWidget { + final double spent; + final double budget; + const _BudgetWarning({required this.spent, required this.budget}); + + @override + Widget build(BuildContext context) { + final over = spent - budget; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.expense.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.expense.withOpacity(0.3)), + ), + child: Row( + children: [ + const Icon(Icons.warning_rounded, color: AppColors.expense, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Budget exceeded by ${_currencyFmt.format(over)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.expense, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + class _BalanceCard extends StatelessWidget { final double balance; const _BalanceCard({required this.balance}); @@ -203,6 +447,22 @@ class _TransactionTile extends StatelessWidget { final WidgetRef ref; const _TransactionTile({required this.transaction, required this.ref}); + void _showUndoSnackBar(BuildContext context, Transaction tx) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Transaction deleted'), + duration: const Duration(seconds: 5), + action: SnackBarAction( + label: 'Undo', + onPressed: () { + ref.read(transactionsProvider.notifier).restore(tx); + }, + ), + backgroundColor: AppColors.surface, + ), + ); + } + @override Widget build(BuildContext context) { final isIncome = transaction.type == TransactionType.income; @@ -222,62 +482,68 @@ class _TransactionTile extends StatelessWidget { ), child: const Icon(Icons.delete_outline_rounded, color: AppColors.expense), ), - onDismissed: (_) => ref.read(transactionsProvider.notifier).delete(transaction.id), - child: Container( - padding: const EdgeInsets.all(14), - 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: catColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(12), + onDismissed: (_) { + ref.read(transactionsProvider.notifier).delete(transaction.id); + _showUndoSnackBar(context, transaction); + }, + child: GestureDetector( + onTap: () => context.push('/add', extra: transaction), + child: Container( + padding: const EdgeInsets.all(14), + 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: catColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(catIcon, color: catColor, size: 20), ), - child: Icon(catIcon, color: catColor, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - transaction.category, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - if (transaction.note != null && transaction.note!.isNotEmpty) + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - transaction.note!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.textSecondary, - ), - overflow: TextOverflow.ellipsis, - ) - else - Text( - DateFormat('MMM d, yyyy').format(transaction.date), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.textSecondary, + transaction.category, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), ), - ], + if (transaction.note != null && transaction.note!.isNotEmpty) + Text( + transaction.note!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ) + else + Text( + DateFormat('MMM d, yyyy').format(transaction.date), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), ), - ), - Text( - '${isIncome ? '+' : '-'}${_currencyFmt.format(transaction.amount)}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: color, - fontWeight: FontWeight.w700, - ), - ), - ], + Text( + '${isIncome ? '+' : '-'}${_currencyFmt.format(transaction.amount)}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), ), ), ); @@ -295,7 +561,7 @@ class _EmptyState extends StatelessWidget { children: [ Container( padding: const EdgeInsets.all(24), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: AppColors.surface, shape: BoxShape.circle, ), @@ -307,7 +573,7 @@ class _EmptyState extends StatelessWidget { ), const SizedBox(height: 16), Text( - 'No transactions yet', + 'No transactions found', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: AppColors.textPrimary, fontWeight: FontWeight.w600, diff --git a/lib/features/settings/provider.dart b/lib/features/settings/provider.dart new file mode 100644 index 0000000..da6d6b3 --- /dev/null +++ b/lib/features/settings/provider.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../dashboard/provider.dart'; + +final budgetProvider = StateNotifierProvider((ref) { + final storage = ref.watch(storageServiceProvider); + return BudgetNotifier(storage.loadBudget(), storage); +}); + +class BudgetNotifier extends StateNotifier { + final dynamic _storage; + + BudgetNotifier(super.initialBudget, this._storage); + + Future setBudget(double? budget) async { + await _storage.saveBudget(budget); + state = budget; + } +} diff --git a/lib/features/settings/screen.dart b/lib/features/settings/screen.dart new file mode 100644 index 0000000..8466857 --- /dev/null +++ b/lib/features/settings/screen.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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}); + + @override + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + final _budgetController = TextEditingController(); + bool _isEditing = false; + + @override + void initState() { + super.initState(); + final budget = ref.read(budgetProvider); + if (budget != null) { + _budgetController.text = budget.toStringAsFixed(2); + } + } + + @override + void dispose() { + _budgetController.dispose(); + super.dispose(); + } + + Future _saveBudget() async { + final text = _budgetController.text.trim(); + if (text.isEmpty) { + await ref.read(budgetProvider.notifier).setBudget(null); + } else { + final value = double.tryParse(text); + if (value != null && value > 0) { + await ref.read(budgetProvider.notifier).setBudget(value); + } + } + setState(() => _isEditing = false); + } + + @override + Widget build(BuildContext context) { + final budget = ref.watch(budgetProvider); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), + children: [ + Text( + 'Settings', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'Manage your preferences', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 32), + 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.account_balance_wallet_rounded, + color: AppColors.accent, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Monthly Budget', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + if (!_isEditing) + IconButton( + icon: const Icon(Icons.edit_rounded, size: 20), + color: AppColors.textSecondary, + onPressed: () => setState(() => _isEditing = true), + ), + ], + ), + const SizedBox(height: 16), + if (_isEditing) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _budgetController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')), + ], + decoration: const InputDecoration( + prefixText: '\$ ', + hintText: '0.00', + helperText: 'Leave empty to remove budget limit', + ), + autofocus: true, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + final budget = ref.read(budgetProvider); + _budgetController.text = budget?.toStringAsFixed(2) ?? ''; + setState(() => _isEditing = false); + }, + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _saveBudget, + style: ElevatedButton.styleFrom( + minimumSize: const Size(80, 40), + ), + child: const Text('Save'), + ), + ], + ), + ], + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + budget != null + ? _currencyFmt.format(budget) + : 'Not set', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: budget != null ? AppColors.accent : AppColors.textSecondary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + budget != null + ? 'Your monthly spending limit' + : 'Set a monthly spending limit to track your budget', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.accent.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.accent.withOpacity(0.3)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline_rounded, color: AppColors.accent, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Budget tracking shows on the Dashboard with a progress bar and warning when exceeded.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/services/storage_service.dart b/lib/shared/services/storage_service.dart index fc51c17..cfe9ed8 100644 --- a/lib/shared/services/storage_service.dart +++ b/lib/shared/services/storage_service.dart @@ -3,14 +3,15 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/transaction.dart'; class StorageService { - static const _key = 'transactions'; + static const _transactionsKey = 'transactions'; + static const _budgetKey = 'monthly_budget'; final SharedPreferences _prefs; StorageService(this._prefs); List loadTransactions() { - final raw = _prefs.getString(_key); + final raw = _prefs.getString(_transactionsKey); if (raw == null) return []; final list = jsonDecode(raw) as List; return list @@ -20,7 +21,7 @@ class StorageService { Future saveTransactions(List transactions) async { final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList()); - await _prefs.setString(_key, encoded); + await _prefs.setString(_transactionsKey, encoded); } Future addTransaction(Transaction transaction) async { @@ -29,8 +30,29 @@ class StorageService { await saveTransactions(list); } + Future updateTransaction(Transaction transaction) async { + final list = loadTransactions(); + final index = list.indexWhere((t) => t.id == transaction.id); + if (index != -1) { + list[index] = transaction; + await saveTransactions(list); + } + } + Future deleteTransaction(String id) async { final list = loadTransactions()..removeWhere((t) => t.id == id); await saveTransactions(list); } + + double? loadBudget() { + return _prefs.getDouble(_budgetKey); + } + + Future saveBudget(double? budget) async { + if (budget == null) { + await _prefs.remove(_budgetKey); + } else { + await _prefs.setDouble(_budgetKey, budget); + } + } }