This commit is contained in:
2026-03-20 09:26:14 +03:00
parent 3dcbb6164e
commit 99d985ca45
11 changed files with 1065 additions and 114 deletions
+19 -1
View File
@@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart';
import '../features/dashboard/screen.dart'; import '../features/dashboard/screen.dart';
import '../features/add_transaction/screen.dart'; import '../features/add_transaction/screen.dart';
import '../features/categories/screen.dart'; import '../features/categories/screen.dart';
import '../features/settings/screen.dart';
import '../shared/models/transaction.dart';
final _shellKey = GlobalKey<NavigatorState>(); final _shellKey = GlobalKey<NavigatorState>();
@@ -25,11 +27,20 @@ final appRouter = GoRouter(
child: CategoriesScreen(), child: CategoriesScreen(),
), ),
), ),
GoRoute(
path: '/settings',
pageBuilder: (context, state) => const NoTransitionPage(
child: SettingsScreen(),
),
),
], ],
), ),
GoRoute( GoRoute(
path: '/add', 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) { int _locationToIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.toString(); final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/categories')) return 1; if (location.startsWith('/categories')) return 1;
if (location.startsWith('/settings')) return 2;
return 0; return 0;
} }
@@ -60,6 +72,7 @@ class AppShell extends StatelessWidget {
onDestinationSelected: (i) { onDestinationSelected: (i) {
if (i == 0) context.go('/dashboard'); if (i == 0) context.go('/dashboard');
if (i == 1) context.go('/categories'); if (i == 1) context.go('/categories');
if (i == 2) context.go('/settings');
}, },
destinations: const [ destinations: const [
NavigationDestination( NavigationDestination(
@@ -72,6 +85,11 @@ class AppShell extends StatelessWidget {
selectedIcon: Icon(Icons.pie_chart_rounded), selectedIcon: Icon(Icons.pie_chart_rounded),
label: 'Categories', label: 'Categories',
), ),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings_rounded),
label: 'Settings',
),
], ],
), ),
); );
+37 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../shared/models/transaction.dart';
class AppColors { class AppColors {
static const background = Color(0xFF0F0F14); static const background = Color(0xFF0F0F14);
@@ -9,16 +10,45 @@ class AppColors {
static const textPrimary = Color(0xFFEEEEF5); static const textPrimary = Color(0xFFEEEEF5);
static const textSecondary = Color(0xFF8888A0); static const textSecondary = Color(0xFF8888A0);
static const divider = Color(0xFF2A2A38); static const divider = Color(0xFF2A2A38);
static const warning = Color(0xFFFFB74D);
} }
class AppCategories { 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<String> forType(TransactionType type) {
return type == TransactionType.expense
? expenseCategories
: incomeCategories;
}
static const icons = { static const icons = {
'Food': Icons.restaurant_rounded, 'Food': Icons.restaurant_rounded,
'Transport': Icons.directions_car_rounded, 'Transport': Icons.directions_car_rounded,
'Shopping': Icons.shopping_bag_rounded, 'Shopping': Icons.shopping_bag_rounded,
'Health': Icons.favorite_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, 'Other': Icons.category_rounded,
}; };
@@ -27,6 +57,12 @@ class AppCategories {
'Transport': Color(0xFF69B4FF), 'Transport': Color(0xFF69B4FF),
'Shopping': Color(0xFFFFD369), 'Shopping': Color(0xFFFFD369),
'Health': Color(0xFF69FFB4), 'Health': Color(0xFF69FFB4),
'Entertainment': Color(0xFFFF69B4),
'Salary': Color(0xFF4CAF8C),
'Freelance': Color(0xFF69FFB4),
'Gift': Color(0xFFFFB469),
'Investment': Color(0xFF69B4FF),
'Refund': Color(0xFFB4FF69),
'Other': Color(0xFFB469FF), 'Other': Color(0xFFB469FF),
}; };
} }
+35 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants.dart';
import '../../shared/models/transaction.dart'; import '../../shared/models/transaction.dart';
class AddTransactionState { class AddTransactionState {
@@ -8,6 +9,7 @@ class AddTransactionState {
final DateTime date; final DateTime date;
final String note; final String note;
final bool isSubmitting; final bool isSubmitting;
final String? editingId;
const AddTransactionState({ const AddTransactionState({
this.amount, this.amount,
@@ -16,6 +18,7 @@ class AddTransactionState {
required this.date, required this.date,
this.note = '', this.note = '',
this.isSubmitting = false, this.isSubmitting = false,
this.editingId,
}); });
AddTransactionState copyWith({ AddTransactionState copyWith({
@@ -25,6 +28,7 @@ class AddTransactionState {
DateTime? date, DateTime? date,
String? note, String? note,
bool? isSubmitting, bool? isSubmitting,
String? editingId,
}) => }) =>
AddTransactionState( AddTransactionState(
amount: amount ?? this.amount, amount: amount ?? this.amount,
@@ -33,7 +37,10 @@ class AddTransactionState {
date: date ?? this.date, date: date ?? this.date,
note: note ?? this.note, note: note ?? this.note,
isSubmitting: isSubmitting ?? this.isSubmitting, isSubmitting: isSubmitting ?? this.isSubmitting,
editingId: editingId ?? this.editingId,
); );
bool get isEditing => editingId != null;
} }
class AddTransactionNotifier extends StateNotifier<AddTransactionState> { class AddTransactionNotifier extends StateNotifier<AddTransactionState> {
@@ -41,11 +48,32 @@ class AddTransactionNotifier extends StateNotifier<AddTransactionState> {
: super(AddTransactionState(date: DateTime.now())); : super(AddTransactionState(date: DateTime.now()));
void setAmount(double? v) => state = state.copyWith(amount: v); void setAmount(double? v) => state = state.copyWith(amount: v);
void setCategory(String v) => state = state.copyWith(category: 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 setDate(DateTime v) => state = state.copyWith(date: v);
void setNote(String v) => state = state.copyWith(note: v); void setNote(String v) => state = state.copyWith(note: v);
void setSubmitting(bool v) => state = state.copyWith(isSubmitting: 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()); void reset() => state = AddTransactionState(date: DateTime.now());
} }
@@ -53,3 +81,9 @@ final addTransactionProvider =
StateNotifierProvider.autoDispose<AddTransactionNotifier, AddTransactionState>( StateNotifierProvider.autoDispose<AddTransactionNotifier, AddTransactionState>(
(ref) => AddTransactionNotifier(), (ref) => AddTransactionNotifier(),
); );
// Reactive categories based on selected type
final availableCategoriesProvider = Provider.autoDispose<List<String>>((ref) {
final type = ref.watch(addTransactionProvider.select((s) => s.type));
return AppCategories.forType(type);
});
+32 -6
View File
@@ -12,7 +12,9 @@ import 'provider.dart';
const _uuid = Uuid(); const _uuid = Uuid();
class AddTransactionScreen extends ConsumerStatefulWidget { class AddTransactionScreen extends ConsumerStatefulWidget {
const AddTransactionScreen({super.key}); final Transaction? initial;
const AddTransactionScreen({super.key, this.initial});
@override @override
ConsumerState<AddTransactionScreen> createState() => ConsumerState<AddTransactionScreen> createState() =>
@@ -24,6 +26,18 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
final _amountController = TextEditingController(); final _amountController = TextEditingController();
final _noteController = 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 @override
void dispose() { void dispose() {
_amountController.dispose(); _amountController.dispose();
@@ -37,7 +51,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
ref.read(addTransactionProvider.notifier).setSubmitting(true); ref.read(addTransactionProvider.notifier).setSubmitting(true);
final tx = Transaction( final tx = Transaction(
id: _uuid.v4(), id: state.editingId ?? _uuid.v4(),
amount: state.amount!, amount: state.amount!,
category: state.category, category: state.category,
type: state.type, type: state.type,
@@ -45,7 +59,12 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
note: state.note.isEmpty ? null : state.note, note: state.note.isEmpty ? null : state.note,
); );
if (state.isEditing) {
await ref.read(transactionsProvider.notifier).update(tx);
} else {
await ref.read(transactionsProvider.notifier).add(tx); await ref.read(transactionsProvider.notifier).add(tx);
}
ref.read(addTransactionProvider.notifier).setSubmitting(false); ref.read(addTransactionProvider.notifier).setSubmitting(false);
if (mounted) context.pop(); if (mounted) context.pop();
@@ -76,11 +95,12 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(addTransactionProvider); final state = ref.watch(addTransactionProvider);
final categories = ref.watch(availableCategoriesProvider);
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
appBar: AppBar( appBar: AppBar(
title: const Text('Add Transaction'), title: Text(state.isEditing ? 'Edit Transaction' : 'Add Transaction'),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
onPressed: () => context.pop(), onPressed: () => context.pop(),
@@ -139,6 +159,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
_SectionLabel('Category'), _SectionLabel('Category'),
const SizedBox(height: 8), const SizedBox(height: 8),
_CategoryPicker( _CategoryPicker(
categories: categories,
selected: state.category, selected: state.category,
onChanged: (c) => onChanged: (c) =>
ref.read(addTransactionProvider.notifier).setCategory(c), ref.read(addTransactionProvider.notifier).setCategory(c),
@@ -200,7 +221,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
color: Colors.white, 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 { class _CategoryPicker extends StatelessWidget {
final List<String> categories;
final String selected; final String selected;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _CategoryPicker({required this.selected, required this.onChanged}); const _CategoryPicker({
required this.categories,
required this.selected,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: AppCategories.all.map((cat) { children: categories.map((cat) {
final isSelected = cat == selected; final isSelected = cat == selected;
final color = AppCategories.colors[cat] ?? AppColors.accent; final color = AppCategories.colors[cat] ?? AppColors.accent;
final icon = AppCategories.icons[cat] ?? Icons.category_rounded; final icon = AppCategories.icons[cat] ?? Icons.category_rounded;
+26
View File
@@ -12,3 +12,29 @@ final categoryExpenseProvider = Provider<Map<String, double>>((ref) {
} }
return map; return map;
}); });
// Monthly breakdown for last 6 months
final monthlyBreakdownProvider = Provider<List<MonthlyData>>((ref) {
final txs = ref.watch(transactionsProvider)
.where((t) => t.type == TransactionType.expense);
final now = DateTime.now();
final months = <MonthlyData>[];
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});
}
+253 -15
View File
@@ -7,6 +7,8 @@ import 'provider.dart';
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
enum ChartType { pie, bar }
class CategoriesScreen extends ConsumerStatefulWidget { class CategoriesScreen extends ConsumerStatefulWidget {
const CategoriesScreen({super.key}); const CategoriesScreen({super.key});
@@ -16,17 +18,30 @@ class CategoriesScreen extends ConsumerStatefulWidget {
class _CategoriesScreenState extends ConsumerState<CategoriesScreen> { class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
int _touchedIndex = -1; int _touchedIndex = -1;
ChartType _chartType = ChartType.pie;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = ref.watch(categoryExpenseProvider); final data = ref.watch(categoryExpenseProvider);
final monthlyData = ref.watch(monthlyBreakdownProvider);
final total = data.values.fold(0.0, (a, b) => a + b); 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( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), padding: const EdgeInsets.fromLTRB(20, 24, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -43,6 +58,15 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
],
),
),
_ChartToggle(
selected: _chartType,
onChanged: (t) => setState(() => _chartType = t),
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
if (data.isEmpty) if (data.isEmpty)
const Expanded(child: _EmptyState()) const Expanded(child: _EmptyState())
@@ -50,21 +74,38 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
Expanded( Expanded(
child: ListView( child: ListView(
children: [ children: [
if (_chartType == ChartType.pie)
_PieChartCard( _PieChartCard(
data: data, data: data,
total: total, total: total,
touchedIndex: _touchedIndex, touchedIndex: _touchedIndex,
onTouch: (i) => setState(() => _touchedIndex = i), onTouch: (i) => setState(() => _touchedIndex = i),
), )
else
_BarChartCard(monthlyData: monthlyData),
const SizedBox(height: 20), const SizedBox(height: 20),
...data.entries.map((e) => Padding( 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), padding: const EdgeInsets.only(bottom: 10),
child: _CategoryRow( child: _CategoryRow(
category: e.key, rank: rank,
amount: e.value, category: cat,
amount: amount,
total: total, total: total,
), ),
)), );
}),
const SizedBox(height: 80), const SizedBox(height: 80),
], ],
), ),
@@ -77,6 +118,68 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
} }
} }
class _ChartToggle extends StatelessWidget {
final ChartType selected;
final ValueChanged<ChartType> 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 { class _PieChartCard extends StatelessWidget {
final Map<String, double> data; final Map<String, double> data;
final double total; final double total;
@@ -171,11 +274,128 @@ class _PieChartCard extends StatelessWidget {
} }
} }
class _BarChartCard extends StatelessWidget {
final List<MonthlyData> 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 { class _CategoryRow extends StatelessWidget {
final int rank;
final String category; final String category;
final double amount; final double amount;
final double total; final double total;
const _CategoryRow({ const _CategoryRow({
required this.rank,
required this.category, required this.category,
required this.amount, required this.amount,
required this.total, required this.total,
@@ -198,6 +418,23 @@ class _CategoryRow extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ 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( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -216,6 +453,9 @@ class _CategoryRow extends StatelessWidget {
), ),
), ),
), ),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text( Text(
_currencyFmt.format(amount), _currencyFmt.format(amount),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
@@ -223,6 +463,14 @@ class _CategoryRow extends StatelessWidget {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
Text(
'${(pct * 100).toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -235,16 +483,6 @@ class _CategoryRow extends StatelessWidget {
minHeight: 6, 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,
),
),
),
], ],
), ),
); );
+59 -4
View File
@@ -27,12 +27,30 @@ class TransactionsNotifier extends StateNotifier<List<Transaction>> {
state = _storage.loadTransactions(); state = _storage.loadTransactions();
} }
Future<void> update(Transaction transaction) async {
await _storage.updateTransaction(transaction);
state = _storage.loadTransactions();
}
Future<void> delete(String id) async { Future<void> delete(String id) async {
await _storage.deleteTransaction(id); await _storage.deleteTransaction(id);
state = _storage.loadTransactions(); state = _storage.loadTransactions();
} }
void restore(Transaction transaction) {
state = [...state, transaction];
_storage.addTransaction(transaction);
}
} }
// Search and filter state
final searchQueryProvider = StateProvider<String>((ref) => '');
enum TransactionFilter { all, income, expense }
final transactionFilterProvider =
StateProvider<TransactionFilter>((ref) => TransactionFilter.all);
// Derived providers // Derived providers
final totalBalanceProvider = Provider<double>((ref) { final totalBalanceProvider = Provider<double>((ref) {
final txs = ref.watch(transactionsProvider); final txs = ref.watch(transactionsProvider);
@@ -55,8 +73,45 @@ final totalExpenseProvider = Provider<double>((ref) {
.fold(0.0, (sum, t) => sum + t.amount); .fold(0.0, (sum, t) => sum + t.amount);
}); });
final recentTransactionsProvider = Provider<List<Transaction>>((ref) { final currentMonthExpenseProvider = Provider<double>((ref) {
final txs = List<Transaction>.from(ref.watch(transactionsProvider)); final now = DateTime.now();
txs.sort((a, b) => b.date.compareTo(a.date)); return ref
return txs.take(20).toList(); .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<List<Transaction>>((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<List<Transaction>>((ref) {
return ref.watch(filteredTransactionsProvider).take(20).toList();
}); });
+273 -7
View File
@@ -1,21 +1,41 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../shared/models/transaction.dart'; import '../../shared/models/transaction.dart';
import '../settings/provider.dart';
import 'provider.dart'; import 'provider.dart';
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
class DashboardScreen extends ConsumerWidget { class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final balance = ref.watch(totalBalanceProvider); final balance = ref.watch(totalBalanceProvider);
final income = ref.watch(totalIncomeProvider); final income = ref.watch(totalIncomeProvider);
final expense = ref.watch(totalExpenseProvider); final expense = ref.watch(totalExpenseProvider);
final monthExpense = ref.watch(currentMonthExpenseProvider);
final budget = ref.watch(budgetProvider);
final recent = ref.watch(recentTransactionsProvider); final recent = ref.watch(recentTransactionsProvider);
final filter = ref.watch(transactionFilterProvider);
final budgetExceeded = budget != null && monthExpense > budget;
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
@@ -45,9 +65,21 @@ class DashboardScreen extends ConsumerWidget {
_BalanceCard(balance: balance), _BalanceCard(balance: balance),
const SizedBox(height: 16), const SizedBox(height: 16),
_SummaryRow(income: income, expense: expense), _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( Text(
'Recent Transactions', 'Transactions',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, 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>(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 { class _BalanceCard extends StatelessWidget {
final double balance; final double balance;
const _BalanceCard({required this.balance}); const _BalanceCard({required this.balance});
@@ -203,6 +447,22 @@ class _TransactionTile extends StatelessWidget {
final WidgetRef ref; final WidgetRef ref;
const _TransactionTile({required this.transaction, required this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isIncome = transaction.type == TransactionType.income; final isIncome = transaction.type == TransactionType.income;
@@ -222,7 +482,12 @@ class _TransactionTile extends StatelessWidget {
), ),
child: const Icon(Icons.delete_outline_rounded, color: AppColors.expense), child: const Icon(Icons.delete_outline_rounded, color: AppColors.expense),
), ),
onDismissed: (_) => ref.read(transactionsProvider.notifier).delete(transaction.id), onDismissed: (_) {
ref.read(transactionsProvider.notifier).delete(transaction.id);
_showUndoSnackBar(context, transaction);
},
child: GestureDetector(
onTap: () => context.push('/add', extra: transaction),
child: Container( child: Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -280,6 +545,7 @@ class _TransactionTile extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }
@@ -295,7 +561,7 @@ class _EmptyState extends StatelessWidget {
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: const BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
@@ -307,7 +573,7 @@ class _EmptyState extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No transactions yet', 'No transactions found',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../dashboard/provider.dart';
final budgetProvider = StateNotifierProvider<BudgetNotifier, double?>((ref) {
final storage = ref.watch(storageServiceProvider);
return BudgetNotifier(storage.loadBudget(), storage);
});
class BudgetNotifier extends StateNotifier<double?> {
final dynamic _storage;
BudgetNotifier(super.initialBudget, this._storage);
Future<void> setBudget(double? budget) async {
await _storage.saveBudget(budget);
state = budget;
}
}
+212
View File
@@ -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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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<void> _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,
),
),
),
],
),
),
],
),
),
);
}
}
+25 -3
View File
@@ -3,14 +3,15 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/transaction.dart'; import '../models/transaction.dart';
class StorageService { class StorageService {
static const _key = 'transactions'; static const _transactionsKey = 'transactions';
static const _budgetKey = 'monthly_budget';
final SharedPreferences _prefs; final SharedPreferences _prefs;
StorageService(this._prefs); StorageService(this._prefs);
List<Transaction> loadTransactions() { List<Transaction> loadTransactions() {
final raw = _prefs.getString(_key); final raw = _prefs.getString(_transactionsKey);
if (raw == null) return []; if (raw == null) return [];
final list = jsonDecode(raw) as List<dynamic>; final list = jsonDecode(raw) as List<dynamic>;
return list return list
@@ -20,7 +21,7 @@ class StorageService {
Future<void> saveTransactions(List<Transaction> transactions) async { Future<void> saveTransactions(List<Transaction> transactions) async {
final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList()); final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList());
await _prefs.setString(_key, encoded); await _prefs.setString(_transactionsKey, encoded);
} }
Future<void> addTransaction(Transaction transaction) async { Future<void> addTransaction(Transaction transaction) async {
@@ -29,8 +30,29 @@ class StorageService {
await saveTransactions(list); await saveTransactions(list);
} }
Future<void> 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<void> deleteTransaction(String id) async { Future<void> deleteTransaction(String id) async {
final list = loadTransactions()..removeWhere((t) => t.id == id); final list = loadTransactions()..removeWhere((t) => t.id == id);
await saveTransactions(list); await saveTransactions(list);
} }
double? loadBudget() {
return _prefs.getDouble(_budgetKey);
}
Future<void> saveBudget(double? budget) async {
if (budget == null) {
await _prefs.remove(_budgetKey);
} else {
await _prefs.setDouble(_budgetKey, budget);
}
}
} }