mirror of
https://github.com/koloideal/Casha.git
synced 2026-06-10 10:25:28 +03:00
update
This commit is contained in:
+19
-1
@@ -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
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user