mirror of
https://github.com/koloideal/Casha.git
synced 2026-06-10 10:25:28 +03:00
update
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../shared/models/transaction.dart';
|
||||
|
||||
class AddTransactionState {
|
||||
@@ -8,6 +9,7 @@ class AddTransactionState {
|
||||
final DateTime date;
|
||||
final String note;
|
||||
final bool isSubmitting;
|
||||
final String? editingId;
|
||||
|
||||
const AddTransactionState({
|
||||
this.amount,
|
||||
@@ -16,6 +18,7 @@ class AddTransactionState {
|
||||
required this.date,
|
||||
this.note = '',
|
||||
this.isSubmitting = false,
|
||||
this.editingId,
|
||||
});
|
||||
|
||||
AddTransactionState copyWith({
|
||||
@@ -25,6 +28,7 @@ class AddTransactionState {
|
||||
DateTime? date,
|
||||
String? note,
|
||||
bool? isSubmitting,
|
||||
String? editingId,
|
||||
}) =>
|
||||
AddTransactionState(
|
||||
amount: amount ?? this.amount,
|
||||
@@ -33,7 +37,10 @@ class AddTransactionState {
|
||||
date: date ?? this.date,
|
||||
note: note ?? this.note,
|
||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||
editingId: editingId ?? this.editingId,
|
||||
);
|
||||
|
||||
bool get isEditing => editingId != null;
|
||||
}
|
||||
|
||||
class AddTransactionNotifier extends StateNotifier<AddTransactionState> {
|
||||
@@ -41,11 +48,32 @@ class AddTransactionNotifier extends StateNotifier<AddTransactionState> {
|
||||
: super(AddTransactionState(date: DateTime.now()));
|
||||
|
||||
void setAmount(double? v) => state = state.copyWith(amount: v);
|
||||
|
||||
void setCategory(String v) => state = state.copyWith(category: v);
|
||||
void setType(TransactionType v) => state = state.copyWith(type: v);
|
||||
|
||||
void setType(TransactionType v) {
|
||||
// Reset category to first item of new type
|
||||
final newCategory = AppCategories.forType(v).first;
|
||||
state = state.copyWith(type: v, category: newCategory);
|
||||
}
|
||||
|
||||
void setDate(DateTime v) => state = state.copyWith(date: v);
|
||||
|
||||
void setNote(String v) => state = state.copyWith(note: v);
|
||||
|
||||
void setSubmitting(bool v) => state = state.copyWith(isSubmitting: v);
|
||||
|
||||
void initializeForEdit(Transaction transaction) {
|
||||
state = AddTransactionState(
|
||||
amount: transaction.amount,
|
||||
category: transaction.category,
|
||||
type: transaction.type,
|
||||
date: transaction.date,
|
||||
note: transaction.note ?? '',
|
||||
editingId: transaction.id,
|
||||
);
|
||||
}
|
||||
|
||||
void reset() => state = AddTransactionState(date: DateTime.now());
|
||||
}
|
||||
|
||||
@@ -53,3 +81,9 @@ final addTransactionProvider =
|
||||
StateNotifierProvider.autoDispose<AddTransactionNotifier, AddTransactionState>(
|
||||
(ref) => AddTransactionNotifier(),
|
||||
);
|
||||
|
||||
// Reactive categories based on selected type
|
||||
final availableCategoriesProvider = Provider.autoDispose<List<String>>((ref) {
|
||||
final type = ref.watch(addTransactionProvider.select((s) => s.type));
|
||||
return AppCategories.forType(type);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ import 'provider.dart';
|
||||
const _uuid = Uuid();
|
||||
|
||||
class AddTransactionScreen extends ConsumerStatefulWidget {
|
||||
const AddTransactionScreen({super.key});
|
||||
final Transaction? initial;
|
||||
|
||||
const AddTransactionScreen({super.key, this.initial});
|
||||
|
||||
@override
|
||||
ConsumerState<AddTransactionScreen> createState() =>
|
||||
@@ -24,6 +26,18 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
|
||||
final _amountController = TextEditingController();
|
||||
final _noteController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initial != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(addTransactionProvider.notifier).initializeForEdit(widget.initial!);
|
||||
_amountController.text = widget.initial!.amount.toString();
|
||||
_noteController.text = widget.initial!.note ?? '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
@@ -37,7 +51,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
|
||||
ref.read(addTransactionProvider.notifier).setSubmitting(true);
|
||||
|
||||
final tx = Transaction(
|
||||
id: _uuid.v4(),
|
||||
id: state.editingId ?? _uuid.v4(),
|
||||
amount: state.amount!,
|
||||
category: state.category,
|
||||
type: state.type,
|
||||
@@ -45,7 +59,12 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
|
||||
note: state.note.isEmpty ? null : state.note,
|
||||
);
|
||||
|
||||
await ref.read(transactionsProvider.notifier).add(tx);
|
||||
if (state.isEditing) {
|
||||
await ref.read(transactionsProvider.notifier).update(tx);
|
||||
} else {
|
||||
await ref.read(transactionsProvider.notifier).add(tx);
|
||||
}
|
||||
|
||||
ref.read(addTransactionProvider.notifier).setSubmitting(false);
|
||||
|
||||
if (mounted) context.pop();
|
||||
@@ -76,11 +95,12 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(addTransactionProvider);
|
||||
final categories = ref.watch(availableCategoriesProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Add Transaction'),
|
||||
title: Text(state.isEditing ? 'Edit Transaction' : 'Add Transaction'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => context.pop(),
|
||||
@@ -139,6 +159,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
|
||||
_SectionLabel('Category'),
|
||||
const SizedBox(height: 8),
|
||||
_CategoryPicker(
|
||||
categories: categories,
|
||||
selected: state.category,
|
||||
onChanged: (c) =>
|
||||
ref.read(addTransactionProvider.notifier).setCategory(c),
|
||||
@@ -200,7 +221,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Save Transaction'),
|
||||
: Text(state.isEditing ? 'Update Transaction' : 'Save Transaction'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -309,16 +330,21 @@ class _TypeOption extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _CategoryPicker extends StatelessWidget {
|
||||
final List<String> categories;
|
||||
final String selected;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _CategoryPicker({required this.selected, required this.onChanged});
|
||||
const _CategoryPicker({
|
||||
required this.categories,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: AppCategories.all.map((cat) {
|
||||
children: categories.map((cat) {
|
||||
final isSelected = cat == selected;
|
||||
final color = AppCategories.colors[cat] ?? AppColors.accent;
|
||||
final icon = AppCategories.icons[cat] ?? Icons.category_rounded;
|
||||
|
||||
@@ -12,3 +12,29 @@ final categoryExpenseProvider = Provider<Map<String, double>>((ref) {
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Monthly breakdown for last 6 months
|
||||
final monthlyBreakdownProvider = Provider<List<MonthlyData>>((ref) {
|
||||
final txs = ref.watch(transactionsProvider)
|
||||
.where((t) => t.type == TransactionType.expense);
|
||||
|
||||
final now = DateTime.now();
|
||||
final months = <MonthlyData>[];
|
||||
|
||||
for (var i = 5; i >= 0; i--) {
|
||||
final month = DateTime(now.year, now.month - i, 1);
|
||||
final total = txs
|
||||
.where((t) => t.date.year == month.year && t.date.month == month.month)
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
months.add(MonthlyData(month: month, amount: total));
|
||||
}
|
||||
|
||||
return months;
|
||||
});
|
||||
|
||||
class MonthlyData {
|
||||
final DateTime month;
|
||||
final double amount;
|
||||
|
||||
MonthlyData({required this.month, required this.amount});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'provider.dart';
|
||||
|
||||
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||
|
||||
enum ChartType { pie, bar }
|
||||
|
||||
class CategoriesScreen extends ConsumerStatefulWidget {
|
||||
const CategoriesScreen({super.key});
|
||||
|
||||
@@ -16,12 +18,18 @@ class CategoriesScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
int _touchedIndex = -1;
|
||||
ChartType _chartType = ChartType.pie;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = ref.watch(categoryExpenseProvider);
|
||||
final monthlyData = ref.watch(monthlyBreakdownProvider);
|
||||
final total = data.values.fold(0.0, (a, b) => a + b);
|
||||
|
||||
// Sort categories by amount descending
|
||||
final sortedEntries = data.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: SafeArea(
|
||||
@@ -30,18 +38,34 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
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,
|
||||
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)
|
||||
@@ -50,21 +74,38 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
_PieChartCard(
|
||||
data: data,
|
||||
total: total,
|
||||
touchedIndex: _touchedIndex,
|
||||
onTouch: (i) => setState(() => _touchedIndex = i),
|
||||
),
|
||||
if (_chartType == ChartType.pie)
|
||||
_PieChartCard(
|
||||
data: data,
|
||||
total: total,
|
||||
touchedIndex: _touchedIndex,
|
||||
onTouch: (i) => setState(() => _touchedIndex = i),
|
||||
)
|
||||
else
|
||||
_BarChartCard(monthlyData: monthlyData),
|
||||
const SizedBox(height: 20),
|
||||
...data.entries.map((e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _CategoryRow(
|
||||
category: e.key,
|
||||
amount: e.value,
|
||||
total: total,
|
||||
Text(
|
||||
'Ranked by Amount',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...sortedEntries.asMap().entries.map((entry) {
|
||||
final rank = entry.key + 1;
|
||||
final cat = entry.value.key;
|
||||
final amount = entry.value.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _CategoryRow(
|
||||
rank: rank,
|
||||
category: cat,
|
||||
amount: amount,
|
||||
total: total,
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
@@ -77,6 +118,68 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChartToggle extends StatelessWidget {
|
||||
final ChartType selected;
|
||||
final ValueChanged<ChartType> onChanged;
|
||||
const _ChartToggle({required this.selected, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.divider),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ToggleButton(
|
||||
icon: Icons.pie_chart_rounded,
|
||||
isSelected: selected == ChartType.pie,
|
||||
onTap: () => onChanged(ChartType.pie),
|
||||
),
|
||||
_ToggleButton(
|
||||
icon: Icons.bar_chart_rounded,
|
||||
isSelected: selected == ChartType.bar,
|
||||
onTap: () => onChanged(ChartType.bar),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToggleButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ToggleButton({
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.accent.withOpacity(0.15) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected ? AppColors.accent : AppColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PieChartCard extends StatelessWidget {
|
||||
final Map<String, double> data;
|
||||
final double total;
|
||||
@@ -171,11 +274,128 @@ class _PieChartCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _BarChartCard extends StatelessWidget {
|
||||
final List<MonthlyData> monthlyData;
|
||||
const _BarChartCard({required this.monthlyData});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxY = monthlyData.map((e) => e.amount).reduce((a, b) => a > b ? a : b);
|
||||
final adjustedMaxY = maxY * 1.2;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.divider),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Last 6 Months',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: adjustedMaxY > 0 ? adjustedMaxY : 100,
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
return BarTooltipItem(
|
||||
_currencyFmt.format(rod.toY),
|
||||
const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value.toInt() >= 0 && value.toInt() < monthlyData.length) {
|
||||
final month = monthlyData[value.toInt()].month;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
DateFormat('MMM').format(month),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: adjustedMaxY > 0 ? adjustedMaxY / 4 : 25,
|
||||
getDrawingHorizontalLine: (value) => FlLine(
|
||||
color: AppColors.divider,
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: List.generate(
|
||||
monthlyData.length,
|
||||
(i) => BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: monthlyData[i].amount,
|
||||
color: AppColors.accent,
|
||||
width: 24,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryRow extends StatelessWidget {
|
||||
final int rank;
|
||||
final String category;
|
||||
final double amount;
|
||||
final double total;
|
||||
const _CategoryRow({
|
||||
required this.rank,
|
||||
required this.category,
|
||||
required this.amount,
|
||||
required this.total,
|
||||
@@ -198,6 +418,23 @@ class _CategoryRow extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: rank <= 3 ? color.withOpacity(0.2) : AppColors.divider,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: rank <= 3 ? color : AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -216,12 +453,23 @@ class _CategoryRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_currencyFmt.format(amount),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.expense,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_currencyFmt.format(amount),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.expense,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(pct * 100).toStringAsFixed(1)}%',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -235,16 +483,6 @@ class _CategoryRow extends StatelessWidget {
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'${(pct * 100).toStringAsFixed(1)}%',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -27,12 +27,30 @@ class TransactionsNotifier extends StateNotifier<List<Transaction>> {
|
||||
state = _storage.loadTransactions();
|
||||
}
|
||||
|
||||
Future<void> update(Transaction transaction) async {
|
||||
await _storage.updateTransaction(transaction);
|
||||
state = _storage.loadTransactions();
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _storage.deleteTransaction(id);
|
||||
state = _storage.loadTransactions();
|
||||
}
|
||||
|
||||
void restore(Transaction transaction) {
|
||||
state = [...state, transaction];
|
||||
_storage.addTransaction(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
// Search and filter state
|
||||
final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||
|
||||
enum TransactionFilter { all, income, expense }
|
||||
|
||||
final transactionFilterProvider =
|
||||
StateProvider<TransactionFilter>((ref) => TransactionFilter.all);
|
||||
|
||||
// Derived providers
|
||||
final totalBalanceProvider = Provider<double>((ref) {
|
||||
final txs = ref.watch(transactionsProvider);
|
||||
@@ -55,8 +73,45 @@ final totalExpenseProvider = Provider<double>((ref) {
|
||||
.fold(0.0, (sum, t) => sum + t.amount);
|
||||
});
|
||||
|
||||
final recentTransactionsProvider = Provider<List<Transaction>>((ref) {
|
||||
final txs = List<Transaction>.from(ref.watch(transactionsProvider));
|
||||
txs.sort((a, b) => b.date.compareTo(a.date));
|
||||
return txs.take(20).toList();
|
||||
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 filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
|
||||
final txs = ref.watch(transactionsProvider);
|
||||
final query = ref.watch(searchQueryProvider).toLowerCase();
|
||||
final filter = ref.watch(transactionFilterProvider);
|
||||
|
||||
var filtered = txs;
|
||||
|
||||
// Apply type filter
|
||||
if (filter == TransactionFilter.income) {
|
||||
filtered = filtered.where((t) => t.type == TransactionType.income).toList();
|
||||
} else if (filter == TransactionFilter.expense) {
|
||||
filtered = filtered.where((t) => t.type == TransactionType.expense).toList();
|
||||
}
|
||||
|
||||
// Apply search query
|
||||
if (query.isNotEmpty) {
|
||||
filtered = filtered.where((t) {
|
||||
final matchesCategory = t.category.toLowerCase().contains(query);
|
||||
final matchesNote = t.note?.toLowerCase().contains(query) ?? false;
|
||||
return matchesCategory || matchesNote;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
filtered.sort((a, b) => b.date.compareTo(a.date));
|
||||
return filtered;
|
||||
});
|
||||
|
||||
final recentTransactionsProvider = Provider<List<Transaction>>((ref) {
|
||||
return ref.watch(filteredTransactionsProvider).take(20).toList();
|
||||
});
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../shared/models/transaction.dart';
|
||||
import '../settings/provider.dart';
|
||||
import 'provider.dart';
|
||||
|
||||
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final balance = ref.watch(totalBalanceProvider);
|
||||
final income = ref.watch(totalIncomeProvider);
|
||||
final expense = ref.watch(totalExpenseProvider);
|
||||
final monthExpense = ref.watch(currentMonthExpenseProvider);
|
||||
final budget = ref.watch(budgetProvider);
|
||||
final recent = ref.watch(recentTransactionsProvider);
|
||||
final filter = ref.watch(transactionFilterProvider);
|
||||
|
||||
final budgetExceeded = budget != null && monthExpense > budget;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
@@ -45,9 +65,21 @@ class DashboardScreen extends ConsumerWidget {
|
||||
_BalanceCard(balance: balance),
|
||||
const SizedBox(height: 16),
|
||||
_SummaryRow(income: income, expense: expense),
|
||||
const SizedBox(height: 28),
|
||||
if (budget != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_BudgetProgress(spent: monthExpense, budget: budget),
|
||||
],
|
||||
if (budgetExceeded) ...[
|
||||
const SizedBox(height: 12),
|
||||
_BudgetWarning(spent: monthExpense, budget: budget),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
_SearchBar(controller: _searchController, ref: ref),
|
||||
const SizedBox(height: 12),
|
||||
_FilterChips(selected: filter, ref: ref),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Recent Transactions',
|
||||
'Transactions',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
@@ -83,6 +115,218 @@ class DashboardScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchBar extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final WidgetRef ref;
|
||||
const _SearchBar({required this.controller, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search transactions...',
|
||||
prefixIcon: const Icon(Icons.search_rounded, color: AppColors.textSecondary),
|
||||
suffixIcon: controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear_rounded, size: 20),
|
||||
color: AppColors.textSecondary,
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
ref.read(searchQueryProvider.notifier).state = '';
|
||||
},
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onChanged: (v) => ref.read(searchQueryProvider.notifier).state = v,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChips extends StatelessWidget {
|
||||
final TransactionFilter selected;
|
||||
final WidgetRef ref;
|
||||
const _FilterChips({required this.selected, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: 'All',
|
||||
isSelected: selected == TransactionFilter.all,
|
||||
onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.all,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Income',
|
||||
isSelected: selected == TransactionFilter.income,
|
||||
color: AppColors.income,
|
||||
onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.income,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
label: 'Expense',
|
||||
isSelected: selected == TransactionFilter.expense,
|
||||
color: AppColors.expense,
|
||||
onTap: () => ref.read(transactionFilterProvider.notifier).state = TransactionFilter.expense,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final Color? color;
|
||||
final VoidCallback onTap;
|
||||
const _FilterChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chipColor = color ?? AppColors.accent;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? chipColor.withOpacity(0.2) : AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? chipColor : AppColors.divider,
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isSelected ? chipColor : AppColors.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BudgetProgress extends StatelessWidget {
|
||||
final double spent;
|
||||
final double budget;
|
||||
const _BudgetProgress({required this.spent, required this.budget});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = spent / budget;
|
||||
final color = ratio >= 1.0
|
||||
? AppColors.expense
|
||||
: ratio >= 0.8
|
||||
? AppColors.warning
|
||||
: AppColors.income;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.divider),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Monthly Budget',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(ratio * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: AppColors.divider,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Spent: ${_currencyFmt.format(spent)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Limit: ${_currencyFmt.format(budget)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BudgetWarning extends StatelessWidget {
|
||||
final double spent;
|
||||
final double budget;
|
||||
const _BudgetWarning({required this.spent, required this.budget});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final over = spent - budget;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.expense.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.expense.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_rounded, color: AppColors.expense, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Budget exceeded by ${_currencyFmt.format(over)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.expense,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BalanceCard extends StatelessWidget {
|
||||
final double balance;
|
||||
const _BalanceCard({required this.balance});
|
||||
@@ -203,6 +447,22 @@ class _TransactionTile extends StatelessWidget {
|
||||
final WidgetRef ref;
|
||||
const _TransactionTile({required this.transaction, required this.ref});
|
||||
|
||||
void _showUndoSnackBar(BuildContext context, Transaction tx) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Transaction deleted'),
|
||||
duration: const Duration(seconds: 5),
|
||||
action: SnackBarAction(
|
||||
label: 'Undo',
|
||||
onPressed: () {
|
||||
ref.read(transactionsProvider.notifier).restore(tx);
|
||||
},
|
||||
),
|
||||
backgroundColor: AppColors.surface,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isIncome = transaction.type == TransactionType.income;
|
||||
@@ -222,62 +482,68 @@ class _TransactionTile extends StatelessWidget {
|
||||
),
|
||||
child: const Icon(Icons.delete_outline_rounded, color: AppColors.expense),
|
||||
),
|
||||
onDismissed: (_) => ref.read(transactionsProvider.notifier).delete(transaction.id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
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: catColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onDismissed: (_) {
|
||||
ref.read(transactionsProvider.notifier).delete(transaction.id);
|
||||
_showUndoSnackBar(context, transaction);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/add', extra: transaction),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
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: catColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(catIcon, color: catColor, size: 20),
|
||||
),
|
||||
child: Icon(catIcon, color: catColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
transaction.category,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (transaction.note != null && transaction.note!.isNotEmpty)
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
transaction.note!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
DateFormat('MMM d, yyyy').format(transaction.date),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
transaction.category,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (transaction.note != null && transaction.note!.isNotEmpty)
|
||||
Text(
|
||||
transaction.note!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
DateFormat('MMM d, yyyy').format(transaction.date),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${isIncome ? '+' : '-'}${_currencyFmt.format(transaction.amount)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'${isIncome ? '+' : '-'}${_currencyFmt.format(transaction.amount)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -295,7 +561,7 @@ class _EmptyState extends StatelessWidget {
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
@@ -307,7 +573,7 @@ class _EmptyState extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No transactions yet',
|
||||
'No transactions found',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../dashboard/provider.dart';
|
||||
|
||||
final budgetProvider = StateNotifierProvider<BudgetNotifier, double?>((ref) {
|
||||
final storage = ref.watch(storageServiceProvider);
|
||||
return BudgetNotifier(storage.loadBudget(), storage);
|
||||
});
|
||||
|
||||
class BudgetNotifier extends StateNotifier<double?> {
|
||||
final dynamic _storage;
|
||||
|
||||
BudgetNotifier(super.initialBudget, this._storage);
|
||||
|
||||
Future<void> setBudget(double? budget) async {
|
||||
await _storage.saveBudget(budget);
|
||||
state = budget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../core/constants.dart';
|
||||
import 'provider.dart';
|
||||
|
||||
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
final _budgetController = TextEditingController();
|
||||
bool _isEditing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final budget = ref.read(budgetProvider);
|
||||
if (budget != null) {
|
||||
_budgetController.text = budget.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_budgetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveBudget() async {
|
||||
final text = _budgetController.text.trim();
|
||||
if (text.isEmpty) {
|
||||
await ref.read(budgetProvider.notifier).setBudget(null);
|
||||
} else {
|
||||
final value = double.tryParse(text);
|
||||
if (value != null && value > 0) {
|
||||
await ref.read(budgetProvider.notifier).setBudget(value);
|
||||
}
|
||||
}
|
||||
setState(() => _isEditing = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final budget = ref.watch(budgetProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
|
||||
children: [
|
||||
Text(
|
||||
'Settings',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Manage your preferences',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.divider),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.account_balance_wallet_rounded,
|
||||
color: AppColors.accent,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Monthly Budget',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isEditing)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_rounded, size: 20),
|
||||
color: AppColors.textSecondary,
|
||||
onPressed: () => setState(() => _isEditing = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isEditing)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _budgetController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
prefixText: '\$ ',
|
||||
hintText: '0.00',
|
||||
helperText: 'Leave empty to remove budget limit',
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final budget = ref.read(budgetProvider);
|
||||
_budgetController.text = budget?.toStringAsFixed(2) ?? '';
|
||||
setState(() => _isEditing = false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _saveBudget,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(80, 40),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
budget != null
|
||||
? _currencyFmt.format(budget)
|
||||
: 'Not set',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: budget != null ? AppColors.accent : AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
budget != null
|
||||
? 'Your monthly spending limit'
|
||||
: 'Set a monthly spending limit to track your budget',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.accent.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded, color: AppColors.accent, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Budget tracking shows on the Dashboard with a progress bar and warning when exceeded.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user