This commit is contained in:
2026-03-20 10:32:36 +03:00
parent 99d985ca45
commit 047d5bdf36
17 changed files with 982 additions and 246 deletions
+26 -19
View File
@@ -21,6 +21,21 @@ class AddTransactionState {
this.editingId,
});
factory AddTransactionState.fromTransaction(Transaction tx) {
return AddTransactionState(
amount: tx.amount,
category: tx.category,
type: tx.type,
date: tx.date,
note: tx.note ?? '',
editingId: tx.id,
);
}
factory AddTransactionState.empty() {
return AddTransactionState(date: DateTime.now());
}
AddTransactionState copyWith({
double? amount,
String? category,
@@ -44,8 +59,10 @@ class AddTransactionState {
}
class AddTransactionNotifier extends StateNotifier<AddTransactionState> {
AddTransactionNotifier()
: super(AddTransactionState(date: DateTime.now()));
AddTransactionNotifier(Transaction? initial)
: super(initial != null
? AddTransactionState.fromTransaction(initial)
: AddTransactionState.empty());
void setAmount(double? v) => state = state.copyWith(amount: v);
@@ -63,27 +80,17 @@ class AddTransactionNotifier extends StateNotifier<AddTransactionState> {
void setSubmitting(bool v) => state = state.copyWith(isSubmitting: v);
void initializeForEdit(Transaction transaction) {
state = AddTransactionState(
amount: transaction.amount,
category: transaction.category,
type: transaction.type,
date: transaction.date,
note: transaction.note ?? '',
editingId: transaction.id,
);
}
void reset() => state = AddTransactionState(date: DateTime.now());
void reset() => state = AddTransactionState.empty();
}
final addTransactionProvider =
StateNotifierProvider.autoDispose<AddTransactionNotifier, AddTransactionState>(
(ref) => AddTransactionNotifier(),
final addTransactionProvider = StateNotifierProvider.autoDispose
.family<AddTransactionNotifier, AddTransactionState, Transaction?>(
(ref, initial) => AddTransactionNotifier(initial),
);
// Reactive categories based on selected type
final availableCategoriesProvider = Provider.autoDispose<List<String>>((ref) {
final type = ref.watch(addTransactionProvider.select((s) => s.type));
final availableCategoriesProvider =
Provider.autoDispose.family<List<String>, Transaction?>((ref, initial) {
final type = ref.watch(addTransactionProvider(initial).select((s) => s.type));
return AppCategories.forType(type);
});
+22 -20
View File
@@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
import '../../core/constants.dart';
import '../../shared/models/transaction.dart';
import '../dashboard/provider.dart';
import '../settings/provider.dart';
import 'provider.dart';
const _uuid = Uuid();
@@ -30,11 +31,8 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
void initState() {
super.initState();
if (widget.initial != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(addTransactionProvider.notifier).initializeForEdit(widget.initial!);
_amountController.text = widget.initial!.amount.toString();
_noteController.text = widget.initial!.note ?? '';
});
_amountController.text = widget.initial!.amount.toString();
_noteController.text = widget.initial!.note ?? '';
}
}
@@ -47,8 +45,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final state = ref.read(addTransactionProvider);
ref.read(addTransactionProvider.notifier).setSubmitting(true);
final state = ref.read(addTransactionProvider(widget.initial));
final currencyInfo = ref.read(currencyProvider);
ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(true);
final tx = Transaction(
id: state.editingId ?? _uuid.v4(),
@@ -57,6 +56,8 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
type: state.type,
date: state.date,
note: state.note.isEmpty ? null : state.note,
currency: currencyInfo.symbol,
currencyCode: currencyInfo.code,
);
if (state.isEditing) {
@@ -65,13 +66,13 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
await ref.read(transactionsProvider.notifier).add(tx);
}
ref.read(addTransactionProvider.notifier).setSubmitting(false);
ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(false);
if (mounted) context.pop();
}
Future<void> _pickDate() async {
final state = ref.read(addTransactionProvider);
final state = ref.read(addTransactionProvider(widget.initial));
final picked = await showDatePicker(
context: context,
initialDate: state.date,
@@ -88,14 +89,15 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
),
);
if (picked != null) {
ref.read(addTransactionProvider.notifier).setDate(picked);
ref.read(addTransactionProvider(widget.initial).notifier).setDate(picked);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(addTransactionProvider);
final categories = ref.watch(availableCategoriesProvider);
final state = ref.watch(addTransactionProvider(widget.initial));
final categories = ref.watch(availableCategoriesProvider(widget.initial));
final currencyInfo = ref.watch(currencyProvider);
return Scaffold(
backgroundColor: AppColors.background,
@@ -116,7 +118,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
_TypeToggle(
selected: state.type,
onChanged: (t) =>
ref.read(addTransactionProvider.notifier).setType(t),
ref.read(addTransactionProvider(widget.initial).notifier).setType(t),
),
const SizedBox(height: 24),
@@ -133,10 +135,10 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
decoration: const InputDecoration(
prefixText: '\$ ',
prefixStyle: TextStyle(
color: AppColors.textSecondary,
decoration: InputDecoration(
prefixText: '${currencyInfo.symbol} ',
prefixStyle: const TextStyle(
color: AppColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.w600,
),
@@ -144,7 +146,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
),
onChanged: (v) {
final parsed = double.tryParse(v);
ref.read(addTransactionProvider.notifier).setAmount(parsed);
ref.read(addTransactionProvider(widget.initial).notifier).setAmount(parsed);
},
validator: (v) {
if (v == null || v.isEmpty) return 'Enter an amount';
@@ -162,7 +164,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
categories: categories,
selected: state.category,
onChanged: (c) =>
ref.read(addTransactionProvider.notifier).setCategory(c),
ref.read(addTransactionProvider(widget.initial).notifier).setCategory(c),
),
const SizedBox(height: 20),
@@ -206,7 +208,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
hintText: 'Add a note...',
),
onChanged: (v) =>
ref.read(addTransactionProvider.notifier).setNote(v),
ref.read(addTransactionProvider(widget.initial).notifier).setNote(v),
),
const SizedBox(height: 32),
+50 -38
View File
@@ -1,12 +1,12 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../core/constants.dart';
import '../settings/provider.dart';
import 'provider.dart';
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
enum ChartType { pie, bar }
class CategoriesScreen extends ConsumerStatefulWidget {
@@ -25,6 +25,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
final data = ref.watch(categoryExpenseProvider);
final monthlyData = ref.watch(monthlyBreakdownProvider);
final total = data.values.fold(0.0, (a, b) => a + b);
final currencyInfo = ref.watch(currencyProvider);
// Sort categories by amount descending
final sortedEntries = data.entries.toList()
@@ -32,42 +33,46 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Categories',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Expense breakdown',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
actions: [
_ChartToggle(
selected: _chartType,
onChanged: (t) => setState(() => _chartType = t),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.add_circle_rounded),
iconSize: 32,
color: AppColors.accent,
onPressed: () => context.push('/add'),
tooltip: 'Add Transaction',
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 0),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Categories',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Expense breakdown',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
_ChartToggle(
selected: _chartType,
onChanged: (t) => setState(() => _chartType = t),
),
],
),
const SizedBox(height: 24),
if (data.isEmpty)
const Expanded(child: _EmptyState())
else
@@ -80,9 +85,10 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
total: total,
touchedIndex: _touchedIndex,
onTouch: (i) => setState(() => _touchedIndex = i),
currency: currencyInfo.symbol,
)
else
_BarChartCard(monthlyData: monthlyData),
_BarChartCard(monthlyData: monthlyData, currency: currencyInfo.symbol),
const SizedBox(height: 20),
Text(
'Ranked by Amount',
@@ -103,6 +109,7 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
category: cat,
amount: amount,
total: total,
currency: currencyInfo.symbol,
),
);
}),
@@ -185,12 +192,14 @@ class _PieChartCard extends StatelessWidget {
final double total;
final int touchedIndex;
final ValueChanged<int> onTouch;
final String currency;
const _PieChartCard({
required this.data,
required this.total,
required this.touchedIndex,
required this.onTouch,
required this.currency,
});
@override
@@ -257,7 +266,7 @@ class _PieChartCard extends StatelessWidget {
),
),
Text(
_currencyFmt.format(total),
'$currency${total.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
@@ -276,7 +285,8 @@ class _PieChartCard extends StatelessWidget {
class _BarChartCard extends StatelessWidget {
final List<MonthlyData> monthlyData;
const _BarChartCard({required this.monthlyData});
final String currency;
const _BarChartCard({required this.monthlyData, required this.currency});
@override
Widget build(BuildContext context) {
@@ -311,7 +321,7 @@ class _BarChartCard extends StatelessWidget {
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
_currencyFmt.format(rod.toY),
'$currency${rod.toY.toStringAsFixed(2)}',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -394,11 +404,13 @@ class _CategoryRow extends StatelessWidget {
final String category;
final double amount;
final double total;
final String currency;
const _CategoryRow({
required this.rank,
required this.category,
required this.amount,
required this.total,
required this.currency,
});
@override
@@ -457,7 +469,7 @@ class _CategoryRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_currencyFmt.format(amount),
'$currency${amount.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.expense,
fontWeight: FontWeight.w700,
+31 -17
View File
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../shared/models/transaction.dart';
import '../../shared/services/storage_service.dart';
import '../settings/provider.dart';
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Override in main');
@@ -51,37 +52,50 @@ enum TransactionFilter { all, income, expense }
final transactionFilterProvider =
StateProvider<TransactionFilter>((ref) => TransactionFilter.all);
// Derived providers
// Converted balance providers (convert all transactions to selected currency)
final totalBalanceProvider = Provider<double>((ref) {
final txs = ref.watch(transactionsProvider);
final exchangeService = ref.watch(exchangeRateServiceProvider);
final targetCurrency = ref.watch(currencyProvider).code;
return txs.fold(0.0, (sum, t) {
return t.type == TransactionType.income ? sum + t.amount : sum - t.amount;
final converted = exchangeService.convert(t.amount, t.currencyCode, targetCurrency);
return t.type == TransactionType.income ? sum + converted : sum - converted;
});
});
final totalIncomeProvider = Provider<double>((ref) {
return ref
.watch(transactionsProvider)
.where((t) => t.type == TransactionType.income)
.fold(0.0, (sum, t) => sum + t.amount);
final txs = ref.watch(transactionsProvider).where((t) => t.type == TransactionType.income);
final exchangeService = ref.watch(exchangeRateServiceProvider);
final targetCurrency = ref.watch(currencyProvider).code;
return txs.fold(0.0, (sum, t) {
return sum + exchangeService.convert(t.amount, t.currencyCode, targetCurrency);
});
});
final totalExpenseProvider = Provider<double>((ref) {
return ref
.watch(transactionsProvider)
.where((t) => t.type == TransactionType.expense)
.fold(0.0, (sum, t) => sum + t.amount);
final txs = ref.watch(transactionsProvider).where((t) => t.type == TransactionType.expense);
final exchangeService = ref.watch(exchangeRateServiceProvider);
final targetCurrency = ref.watch(currencyProvider).code;
return txs.fold(0.0, (sum, t) {
return sum + exchangeService.convert(t.amount, t.currencyCode, targetCurrency);
});
});
final currentMonthExpenseProvider = Provider<double>((ref) {
final now = DateTime.now();
return ref
.watch(transactionsProvider)
.where((t) =>
t.type == TransactionType.expense &&
t.date.year == now.year &&
t.date.month == now.month)
.fold(0.0, (sum, t) => sum + t.amount);
final txs = ref.watch(transactionsProvider).where((t) =>
t.type == TransactionType.expense &&
t.date.year == now.year &&
t.date.month == now.month);
final exchangeService = ref.watch(exchangeRateServiceProvider);
final targetCurrency = ref.watch(currencyProvider).code;
return txs.fold(0.0, (sum, t) {
return sum + exchangeService.convert(t.amount, t.currencyCode, targetCurrency);
});
});
final filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
+76 -41
View File
@@ -7,8 +7,6 @@ import '../../shared/models/transaction.dart';
import '../settings/provider.dart';
import 'provider.dart';
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key});
@@ -34,44 +32,67 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final budget = ref.watch(budgetProvider);
final recent = ref.watch(recentTransactionsProvider);
final filter = ref.watch(transactionFilterProvider);
final currencyInfo = ref.watch(currencyProvider);
final budgetExceeded = budget != null && monthExpense > budget;
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'My Finances',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
DateFormat('MMMM yyyy').format(DateTime.now()),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_rounded),
iconSize: 32,
color: AppColors.accent,
onPressed: () => context.push('/add'),
tooltip: 'Add Transaction',
),
],
),
body: SafeArea(
child: CustomScrollView(
cacheExtent: 500,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 0),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'My Finances',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
DateFormat('MMMM yyyy').format(DateTime.now()),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 24),
_BalanceCard(balance: balance),
_BalanceCard(balance: balance, currencyInfo: currencyInfo),
const SizedBox(height: 16),
_SummaryRow(income: income, expense: expense),
_SummaryRow(income: income, expense: expense, currencyInfo: currencyInfo),
if (budget != null) ...[
const SizedBox(height: 16),
_BudgetProgress(spent: monthExpense, budget: budget),
_BudgetProgress(spent: monthExpense, budget: budget, currencyInfo: currencyInfo),
],
if (budgetExceeded) ...[
const SizedBox(height: 12),
_BudgetWarning(spent: monthExpense, budget: budget),
_BudgetWarning(spent: monthExpense, budget: budget, currencyInfo: currencyInfo),
],
const SizedBox(height: 24),
_SearchBar(controller: _searchController, ref: ref),
@@ -102,7 +123,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
delegate: SliverChildBuilderDelegate(
(context, i) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _TransactionTile(transaction: recent[i], ref: ref),
child: _TransactionTile(
transaction: recent[i],
),
),
childCount: recent.length,
),
@@ -220,7 +243,8 @@ class _FilterChip extends StatelessWidget {
class _BudgetProgress extends StatelessWidget {
final double spent;
final double budget;
const _BudgetProgress({required this.spent, required this.budget});
final CurrencyInfo currencyInfo;
const _BudgetProgress({required this.spent, required this.budget, required this.currencyInfo});
@override
Widget build(BuildContext context) {
@@ -274,13 +298,13 @@ class _BudgetProgress extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Spent: ${_currencyFmt.format(spent)}',
'Spent: ${currencyInfo.symbol}${spent.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
Text(
'Limit: ${_currencyFmt.format(budget)}',
'Limit: ${currencyInfo.symbol}${budget.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
@@ -296,7 +320,8 @@ class _BudgetProgress extends StatelessWidget {
class _BudgetWarning extends StatelessWidget {
final double spent;
final double budget;
const _BudgetWarning({required this.spent, required this.budget});
final CurrencyInfo currencyInfo;
const _BudgetWarning({required this.spent, required this.budget, required this.currencyInfo});
@override
Widget build(BuildContext context) {
@@ -314,7 +339,7 @@ class _BudgetWarning extends StatelessWidget {
const SizedBox(width: 10),
Expanded(
child: Text(
'Budget exceeded by ${_currencyFmt.format(over)}',
'Budget exceeded by ${currencyInfo.symbol}${over.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.expense,
fontWeight: FontWeight.w600,
@@ -329,7 +354,8 @@ class _BudgetWarning extends StatelessWidget {
class _BalanceCard extends StatelessWidget {
final double balance;
const _BalanceCard({required this.balance});
final CurrencyInfo currencyInfo;
const _BalanceCard({required this.balance, required this.currencyInfo});
@override
Widget build(BuildContext context) {
@@ -362,13 +388,21 @@ class _BalanceCard extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
_currencyFmt.format(balance),
'${currencyInfo.symbol}${balance.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
Text(
'Converted to ${currencyInfo.symbol}${currencyInfo.code}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white60,
fontSize: 11,
),
),
],
),
);
@@ -378,15 +412,16 @@ class _BalanceCard extends StatelessWidget {
class _SummaryRow extends StatelessWidget {
final double income;
final double expense;
const _SummaryRow({required this.income, required this.expense});
final CurrencyInfo currencyInfo;
const _SummaryRow({required this.income, required this.expense, required this.currencyInfo});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: _SummaryCard(label: 'Income', amount: income, color: AppColors.income, icon: Icons.arrow_downward_rounded)),
Expanded(child: _SummaryCard(label: 'Income', amount: income, color: AppColors.income, icon: Icons.arrow_downward_rounded, currencyInfo: currencyInfo)),
const SizedBox(width: 12),
Expanded(child: _SummaryCard(label: 'Expenses', amount: expense, color: AppColors.expense, icon: Icons.arrow_upward_rounded)),
Expanded(child: _SummaryCard(label: 'Expenses', amount: expense, color: AppColors.expense, icon: Icons.arrow_upward_rounded, currencyInfo: currencyInfo)),
],
);
}
@@ -397,7 +432,8 @@ class _SummaryCard extends StatelessWidget {
final double amount;
final Color color;
final IconData icon;
const _SummaryCard({required this.label, required this.amount, required this.color, required this.icon});
final CurrencyInfo currencyInfo;
const _SummaryCard({required this.label, required this.amount, required this.color, required this.icon, required this.currencyInfo});
@override
Widget build(BuildContext context) {
@@ -426,7 +462,7 @@ class _SummaryCard extends StatelessWidget {
Text(label, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: AppColors.textSecondary)),
const SizedBox(height: 2),
Text(
_currencyFmt.format(amount),
'${currencyInfo.symbol}${amount.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w600,
@@ -442,12 +478,11 @@ class _SummaryCard extends StatelessWidget {
}
}
class _TransactionTile extends StatelessWidget {
class _TransactionTile extends ConsumerWidget {
final Transaction transaction;
final WidgetRef ref;
const _TransactionTile({required this.transaction, required this.ref});
const _TransactionTile({required this.transaction});
void _showUndoSnackBar(BuildContext context, Transaction tx) {
void _showUndoSnackBar(BuildContext context, WidgetRef ref, Transaction tx) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Transaction deleted'),
@@ -464,7 +499,7 @@ class _TransactionTile extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final isIncome = transaction.type == TransactionType.income;
final color = isIncome ? AppColors.income : AppColors.expense;
final catColor = AppCategories.colors[transaction.category] ?? AppColors.accent;
@@ -484,7 +519,7 @@ class _TransactionTile extends StatelessWidget {
),
onDismissed: (_) {
ref.read(transactionsProvider.notifier).delete(transaction.id);
_showUndoSnackBar(context, transaction);
_showUndoSnackBar(context, ref, transaction);
},
child: GestureDetector(
onTap: () => context.push('/add', extra: transaction),
@@ -536,7 +571,7 @@ class _TransactionTile extends StatelessWidget {
),
),
Text(
'${isIncome ? '+' : '-'}${_currencyFmt.format(transaction.amount)}',
'${isIncome ? '+' : '-'}${transaction.currency}${transaction.amount.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w700,
+116
View File
@@ -1,4 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../shared/services/exchange_rate_service.dart';
import '../dashboard/provider.dart';
final budgetProvider = StateNotifierProvider<BudgetNotifier, double?>((ref) {
@@ -16,3 +22,113 @@ class BudgetNotifier extends StateNotifier<double?> {
state = budget;
}
}
// Currency info: symbol and code
class CurrencyInfo {
final String symbol;
final String code;
const CurrencyInfo(this.symbol, this.code);
}
const Map<String, CurrencyInfo> currencyMap = {
'USD': CurrencyInfo('\$', 'USD'),
'EUR': CurrencyInfo('', 'EUR'),
'BYN': CurrencyInfo('Br', 'BYN'),
'RUB': CurrencyInfo('', 'RUB'),
};
class CurrencyNotifier extends StateNotifier<CurrencyInfo> {
CurrencyNotifier() : super(currencyMap['USD']!) {
_load();
}
void _load() async {
final prefs = await SharedPreferences.getInstance();
final code = prefs.getString('currency_code') ?? 'USD';
state = currencyMap[code] ?? currencyMap['USD']!;
}
Future<void> setCurrency(String code) async {
final prefs = await SharedPreferences.getInstance();
state = currencyMap[code] ?? currencyMap['USD']!;
await prefs.setString('currency_code', code);
}
}
final currencyProvider = StateNotifierProvider<CurrencyNotifier, CurrencyInfo>(
(ref) => CurrencyNotifier(),
);
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
ThemeModeNotifier() : super(ThemeMode.dark) {
_load();
}
void _load() async {
final prefs = await SharedPreferences.getInstance();
state = (prefs.getBool('dark_mode') ?? true) ? ThemeMode.dark : ThemeMode.light;
}
Future<void> toggle() async {
final prefs = await SharedPreferences.getInstance();
state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
await prefs.setBool('dark_mode', state == ThemeMode.dark);
}
Future<void> setThemeMode(bool isDark) async {
final prefs = await SharedPreferences.getInstance();
state = isDark ? ThemeMode.dark : ThemeMode.light;
await prefs.setBool('dark_mode', isDark);
}
}
final themeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeMode>(
(ref) => ThemeModeNotifier(),
);
// Exchange rate service
final exchangeRateServiceProvider = Provider<ExchangeRateService>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ExchangeRateService(prefs);
});
final ratesInitProvider = FutureProvider<void>((ref) async {
await ref.read(exchangeRateServiceProvider).fetchRates();
});
final exportProvider = Provider<ExportService>((ref) {
return ExportService(ref);
});
class ExportService {
final Ref _ref;
ExportService(this._ref);
Future<String> exportToCSV() async {
final transactions = _ref.read(transactionsProvider);
final currency = _ref.read(currencyProvider);
// CSV header
final buffer = StringBuffer();
buffer.writeln('Date,Type,Category,Amount,Currency,Note');
// CSV rows
for (final tx in transactions) {
final date = DateFormat('yyyy-MM-dd').format(tx.date);
final type = tx.type.name;
final category = tx.category;
final amount = '${tx.currency}${tx.amount.toStringAsFixed(2)}';
final note = tx.note?.replaceAll(',', ';') ?? '';
buffer.writeln('$date,$type,$category,$amount,${tx.currencyCode},$note');
}
// Save to Downloads
final directory = await getApplicationDocumentsDirectory();
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
final file = File('${directory.path}/transactions_$timestamp.csv');
await file.writeAsString(buffer.toString());
return file.path;
}
}
+181 -10
View File
@@ -1,12 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../core/constants.dart';
import 'provider.dart';
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@@ -17,10 +16,13 @@ class SettingsScreen extends ConsumerStatefulWidget {
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final _budgetController = TextEditingController();
bool _isEditing = false;
late NumberFormat _currencyFmt;
@override
void initState() {
super.initState();
final currencyInfo = ref.read(currencyProvider);
_currencyFmt = NumberFormat.currency(symbol: currencyInfo.symbol, decimalDigits: 2);
final budget = ref.read(budgetProvider);
if (budget != null) {
_budgetController.text = budget.toStringAsFixed(2);
@@ -49,27 +51,196 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
final budget = ref.watch(budgetProvider);
final themeMode = ref.watch(themeProvider);
final isDarkMode = themeMode == ThemeMode.dark;
final currencyInfo = ref.watch(currencyProvider);
// Update currency format when it changes
_currencyFmt = NumberFormat.currency(symbol: currencyInfo.symbol, decimalDigits: 2);
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Settings',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Manage your preferences',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 32),
],
),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_rounded),
iconSize: 32,
color: AppColors.accent,
onPressed: () => context.push('/add'),
tooltip: 'Add Transaction',
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
children: [
// Theme Toggle
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.divider),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.accent.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isDarkMode ? Icons.dark_mode_rounded : Icons.light_mode_rounded,
color: AppColors.accent,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dark Mode',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
Text(
isDarkMode ? 'Enabled' : 'Disabled',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
Switch(
value: isDarkMode,
onChanged: (value) {
ref.read(themeProvider.notifier).setThemeMode(value);
},
activeColor: AppColors.accent,
),
],
),
),
const SizedBox(height: 16),
// Currency Selector
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.divider),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.accent.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.attach_money_rounded,
color: AppColors.accent,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Currency',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: ['USD', 'EUR', 'BYN', 'RUB'].map((code) {
final info = currencyMap[code]!;
final isSelected = currencyInfo.code == code;
return Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () {
ref.read(currencyProvider.notifier).setCurrency(code);
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected
? AppColors.accent.withOpacity(0.2)
: AppColors.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColors.accent : AppColors.divider,
width: isSelected ? 1.5 : 1,
),
),
child: Column(
children: [
Text(
info.symbol,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: isSelected ? AppColors.accent : AppColors.textSecondary,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.normal,
),
),
const SizedBox(height: 2),
Text(
code,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isSelected ? AppColors.accent : AppColors.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
),
),
);
}).toList(),
),
],
),
),
const SizedBox(height: 16),
// Budget Setting
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@@ -123,8 +294,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
],
decoration: const InputDecoration(
prefixText: '\$ ',
decoration: InputDecoration(
prefixText: '${currencyInfo.symbol} ',
hintText: '0.00',
helperText: 'Leave empty to remove budget limit',
),