mirror of
https://github.com/koloideal/Casha.git
synced 2026-06-10 02:15:29 +03:00
update
This commit is contained in:
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
+192
-89
@@ -2,100 +2,203 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../core/constants.dart';
|
||||
|
||||
ThemeData buildAppTheme() {
|
||||
final base = ThemeData.dark(useMaterial3: true);
|
||||
final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply(
|
||||
bodyColor: AppColors.textPrimary,
|
||||
displayColor: AppColors.textPrimary,
|
||||
);
|
||||
class AppTheme {
|
||||
static ThemeData get darkTheme {
|
||||
final base = ThemeData.dark(useMaterial3: true);
|
||||
final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply(
|
||||
bodyColor: AppColors.textPrimary,
|
||||
displayColor: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
textTheme: textTheme,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
surface: AppColors.surface,
|
||||
primary: AppColors.accent,
|
||||
secondary: AppColors.accent,
|
||||
onPrimary: Colors.white,
|
||||
onSurface: AppColors.textPrimary,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
return base.copyWith(
|
||||
textTheme: textTheme,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
surface: AppColors.surface,
|
||||
primary: AppColors.accent,
|
||||
secondary: AppColors.accent,
|
||||
onPrimary: Colors.white,
|
||||
onSurface: AppColors.textPrimary,
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.background,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: GoogleFonts.poppins(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
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: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const IconThemeData(color: AppColors.accent);
|
||||
}
|
||||
return const IconThemeData(color: AppColors.textSecondary);
|
||||
}),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.divider),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.divider),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.accent, width: 1.5),
|
||||
),
|
||||
labelStyle: const TextStyle(color: AppColors.textSecondary),
|
||||
hintStyle: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
textStyle: GoogleFonts.poppins(
|
||||
fontSize: 16,
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.background,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: GoogleFonts.poppins(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.divider,
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
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: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const IconThemeData(color: AppColors.accent);
|
||||
}
|
||||
return const IconThemeData(color: AppColors.textSecondary);
|
||||
}),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.divider),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.divider),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.accent, width: 1.5),
|
||||
),
|
||||
labelStyle: const TextStyle(color: AppColors.textSecondary),
|
||||
hintStyle: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
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: AppColors.divider,
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user