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
+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+12 -3
View File
@@ -1,16 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'router.dart';
import 'theme.dart';
import '../features/settings/provider.dart';
class App extends StatelessWidget {
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeProvider);
// Trigger exchange rate fetch on app start
ref.watch(ratesInitProvider);
return MaterialApp.router(
title: 'Finance Tracker',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
routerConfig: appRouter,
);
}
+1 -7
View File
@@ -29,7 +29,7 @@ final appRouter = GoRouter(
),
GoRoute(
path: '/settings',
pageBuilder: (context, state) => const NoTransitionPage(
pageBuilder: (context, state) => NoTransitionPage(
child: SettingsScreen(),
),
),
@@ -61,12 +61,6 @@ class AppShell extends StatelessWidget {
final idx = _locationToIndex(context);
return Scaffold(
body: child,
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/add'),
backgroundColor: const Color(0xFF7C6DED),
child: const Icon(Icons.add, color: Colors.white),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: NavigationBar(
selectedIndex: idx,
onDestinationSelected: (i) {
+104 -1
View File
@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../core/constants.dart';
ThemeData buildAppTheme() {
class AppTheme {
static ThemeData get darkTheme {
final base = ThemeData.dark(useMaterial3: true);
final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply(
bodyColor: AppColors.textPrimary,
@@ -99,3 +100,105 @@ ThemeData buildAppTheme() {
),
);
}
static ThemeData get lightTheme {
final base = ThemeData.light(useMaterial3: true);
final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply(
bodyColor: const Color(0xFF1A1A24),
displayColor: const Color(0xFF1A1A24),
);
return base.copyWith(
textTheme: textTheme,
scaffoldBackgroundColor: const Color(0xFFF5F5F5),
colorScheme: const ColorScheme.light(
surface: Colors.white,
primary: AppColors.accent,
secondary: AppColors.accent,
onPrimary: Colors.white,
onSurface: Color(0xFF1A1A24),
),
cardTheme: CardThemeData(
color: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
margin: EdgeInsets.zero,
),
appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0,
centerTitle: false,
titleTextStyle: GoogleFonts.poppins(
color: const Color(0xFF1A1A24),
fontSize: 20,
fontWeight: FontWeight.w600,
),
iconTheme: const IconThemeData(color: Color(0xFF1A1A24)),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.white,
indicatorColor: AppColors.accent.withOpacity(0.2),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return GoogleFonts.poppins(
color: AppColors.accent,
fontSize: 12,
fontWeight: FontWeight.w600,
);
}
return GoogleFonts.poppins(
color: const Color(0xFF666666),
fontSize: 12,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(color: AppColors.accent);
}
return const IconThemeData(color: Color(0xFF666666));
}),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.accent, width: 1.5),
),
labelStyle: const TextStyle(color: Color(0xFF666666)),
hintStyle: const TextStyle(color: Color(0xFF999999)),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accent,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: GoogleFonts.poppins(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
dividerTheme: const DividerThemeData(
color: Color(0xFFE0E0E0),
thickness: 1,
),
);
}
}
// Keep for backward compatibility
ThemeData buildAppTheme() => AppTheme.darkTheme;
+37
View File
@@ -11,6 +11,13 @@ class AppColors {
static const textSecondary = Color(0xFF8888A0);
static const divider = Color(0xFF2A2A38);
static const warning = Color(0xFFFFB74D);
// Light theme colors
static const lightBackground = Color(0xFFF5F5F7);
static const lightSurface = Color(0xFFFFFFFF);
static const lightTextPrimary = Color(0xFF1A1A24);
static const lightTextSecondary = Color(0xFF6B6B80);
static const lightDivider = Color(0xFFE0E0E8);
}
class AppCategories {
@@ -66,3 +73,33 @@ class AppCategories {
'Other': Color(0xFFB469FF),
};
}
class CurrencyOption {
final String symbol;
final String name;
final String code;
const CurrencyOption({
required this.symbol,
required this.name,
required this.code,
});
}
class AppCurrencies {
static const options = [
CurrencyOption(symbol: '\$', name: 'US Dollar', code: 'USD'),
CurrencyOption(symbol: '', name: 'Euro', code: 'EUR'),
CurrencyOption(symbol: '£', name: 'British Pound', code: 'GBP'),
CurrencyOption(symbol: 'Br', name: 'Belarusian Ruble', code: 'BYN'),
CurrencyOption(symbol: '', name: 'Russian Ruble', code: 'RUB'),
CurrencyOption(symbol: '', name: 'Ukrainian Hryvnia', code: 'UAH'),
];
static CurrencyOption findBySymbol(String symbol) {
return options.firstWhere(
(c) => c.symbol == symbol,
orElse: () => options.first,
);
}
}
+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.empty();
}
void reset() => state = AddTransactionState(date: DateTime.now());
}
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);
});
+20 -18
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 ?? '';
});
}
}
@@ -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),
+34 -22
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,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Categories',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Expense breakdown',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
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',
),
],
),
const SizedBox(height: 24),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
+29 -15
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) =>
final txs = 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);
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) {
+70 -35
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,
body: SafeArea(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 0),
appBar: AppBar(
title: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'My Finances',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
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.bodyMedium?.copyWith(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 24),
_BalanceCard(balance: balance),
],
),
),
],
),
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, 16, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_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',
),
+33
View File
@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart';
enum TransactionType { income, expense }
enum RecurrenceType { none, daily, weekly, monthly }
@immutable
class Transaction {
final String id;
@@ -10,6 +12,10 @@ class Transaction {
final TransactionType type;
final DateTime date;
final String? note;
final RecurrenceType recurrence;
final DateTime? lastOccurrence;
final String currency;
final String currencyCode;
const Transaction({
required this.id,
@@ -18,6 +24,10 @@ class Transaction {
required this.type,
required this.date,
this.note,
this.recurrence = RecurrenceType.none,
this.lastOccurrence,
this.currency = '\$',
this.currencyCode = 'USD',
});
factory Transaction.fromJson(Map<String, dynamic> json) => Transaction(
@@ -29,6 +39,17 @@ class Transaction {
),
date: DateTime.parse(json['date'] as String),
note: json['note'] as String?,
recurrence: json['recurrence'] != null
? RecurrenceType.values.firstWhere(
(e) => e.name == json['recurrence'],
orElse: () => RecurrenceType.none,
)
: RecurrenceType.none,
lastOccurrence: json['lastOccurrence'] != null
? DateTime.parse(json['lastOccurrence'] as String)
: null,
currency: json['currency'] as String? ?? '\$',
currencyCode: json['currencyCode'] as String? ?? 'USD',
);
Map<String, dynamic> toJson() => {
@@ -38,6 +59,10 @@ class Transaction {
'type': type.name,
'date': date.toIso8601String(),
'note': note,
'recurrence': recurrence.name,
'lastOccurrence': lastOccurrence?.toIso8601String(),
'currency': currency,
'currencyCode': currencyCode,
};
Transaction copyWith({
@@ -47,6 +72,10 @@ class Transaction {
TransactionType? type,
DateTime? date,
String? note,
RecurrenceType? recurrence,
DateTime? lastOccurrence,
String? currency,
String? currencyCode,
}) =>
Transaction(
id: id ?? this.id,
@@ -55,5 +84,9 @@ class Transaction {
type: type ?? this.type,
date: date ?? this.date,
note: note ?? this.note,
recurrence: recurrence ?? this.recurrence,
lastOccurrence: lastOccurrence ?? this.lastOccurrence,
currency: currency ?? this.currency,
currencyCode: currencyCode ?? this.currencyCode,
);
}
@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class ExchangeRateService {
static const String _primaryUrl = 'https://open.er-api.com/v6/latest/USD';
static const String _fallbackUrl =
'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json';
static const String _cacheKey = 'exchange_rates';
static const Map<String, double> _fallbackRates = {
'USD': 1.0,
'EUR': 0.92,
'BYN': 3.25,
'RUB': 90.0,
};
final SharedPreferences _prefs;
Map<String, double> _rates = {};
ExchangeRateService(this._prefs) {
_loadCachedRates();
}
Map<String, double> get currentRates => _rates.isEmpty ? _fallbackRates : _rates;
void _loadCachedRates() {
final cached = _prefs.getString(_cacheKey);
if (cached != null) {
try {
final decoded = jsonDecode(cached) as Map<String, dynamic>;
_rates = decoded.map((k, v) => MapEntry(k, (v as num).toDouble()));
} catch (e) {
_rates = Map.from(_fallbackRates);
}
} else {
_rates = Map.from(_fallbackRates);
}
}
Future<void> fetchRates() async {
try {
// Try primary URL
final response = await http
.get(Uri.parse(_primaryUrl))
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['rates'] != null) {
final rates = data['rates'] as Map<String, dynamic>;
_rates = {
'USD': 1.0,
'EUR': (rates['EUR'] as num?)?.toDouble() ?? _fallbackRates['EUR']!,
'BYN': (rates['BYN'] as num?)?.toDouble() ?? _fallbackRates['BYN']!,
'RUB': (rates['RUB'] as num?)?.toDouble() ?? _fallbackRates['RUB']!,
};
await _cacheRates();
return;
}
}
} catch (e) {
// Primary failed, try fallback
}
try {
// Try fallback URL
final response = await http
.get(Uri.parse(_fallbackUrl))
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['usd'] != null) {
final rates = data['usd'] as Map<String, dynamic>;
_rates = {
'USD': 1.0,
'EUR': (rates['eur'] as num?)?.toDouble() ?? _fallbackRates['EUR']!,
'BYN': (rates['byn'] as num?)?.toDouble() ?? _fallbackRates['BYN']!,
'RUB': (rates['rub'] as num?)?.toDouble() ?? _fallbackRates['RUB']!,
};
await _cacheRates();
return;
}
}
} catch (e) {
// Both failed, use cached or fallback
}
// If both failed and no cache, use fallback
if (_rates.isEmpty) {
_rates = Map.from(_fallbackRates);
}
}
Future<void> _cacheRates() async {
final encoded = jsonEncode(_rates);
await _prefs.setString(_cacheKey, encoded);
}
double convert(double amount, String from, String to) {
if (from == to) return amount;
final fromRate = currentRates[from] ?? 1.0;
final toRate = currentRates[to] ?? 1.0;
// Convert to USD first, then to target currency
final amountInUsd = amount / fromRate;
return amountInUsd * toRate;
}
}
+87
View File
@@ -1,10 +1,15 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../models/transaction.dart';
const _uuid = Uuid();
class StorageService {
static const _transactionsKey = 'transactions';
static const _budgetKey = 'monthly_budget';
static const _currencyKey = 'currency_symbol';
static const _themeKey = 'is_dark_mode';
final SharedPreferences _prefs;
@@ -55,4 +60,86 @@ class StorageService {
await _prefs.setDouble(_budgetKey, budget);
}
}
String loadCurrency() {
return _prefs.getString(_currencyKey) ?? '\$';
}
Future<void> saveCurrency(String symbol) async {
await _prefs.setString(_currencyKey, symbol);
}
bool loadThemeMode() {
return _prefs.getBool(_themeKey) ?? true; // default dark
}
Future<void> saveThemeMode(bool isDark) async {
await _prefs.setBool(_themeKey, isDark);
}
// Process recurring transactions
Future<void> processRecurringTransactions() async {
final transactions = loadTransactions();
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
bool hasChanges = false;
for (final tx in transactions) {
if (tx.recurrence == RecurrenceType.none) continue;
final lastOccurrence = tx.lastOccurrence ?? tx.date;
final lastDate = DateTime(
lastOccurrence.year,
lastOccurrence.month,
lastOccurrence.day,
);
bool shouldCreate = false;
switch (tx.recurrence) {
case RecurrenceType.daily:
shouldCreate = today.isAfter(lastDate);
break;
case RecurrenceType.weekly:
final daysDiff = today.difference(lastDate).inDays;
shouldCreate = daysDiff >= 7;
break;
case RecurrenceType.monthly:
shouldCreate = (today.year > lastDate.year ||
(today.year == lastDate.year &&
today.month > lastDate.month)) &&
today.day >= lastDate.day;
break;
case RecurrenceType.none:
break;
}
if (shouldCreate) {
// Create new occurrence
final newTx = Transaction(
id: _uuid.v4(),
amount: tx.amount,
category: tx.category,
type: tx.type,
date: today,
note: tx.note,
recurrence: tx.recurrence,
lastOccurrence: today,
);
transactions.add(newTx);
// Update original transaction's lastOccurrence
final index = transactions.indexWhere((t) => t.id == tx.id);
if (index != -1) {
transactions[index] = tx.copyWith(lastOccurrence: today);
}
hasChanges = true;
}
}
if (hasChanges) {
await saveTransactions(transactions);
}
}
}
+2 -2
View File
@@ -177,7 +177,7 @@ packages:
source: hosted
version: "1.0.2"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -289,7 +289,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+2
View File
@@ -17,6 +17,8 @@ dependencies:
google_fonts: ^6.2.1
intl: ^0.19.0
uuid: ^4.5.1
path_provider: ^2.1.5
http: ^1.2.0
dev_dependencies:
flutter_test: