ynab_lite/lib/screens/budget_screen.dart

218 lines
7.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/db_provider.dart';
import '../theme/app_theme.dart';
import 'package:drift/drift.dart' show Value;
import '../data/db.dart';
class BudgetScreen extends ConsumerWidget {
const BudgetScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final groups = ref.watch(categoryGroupsProvider).valueOrNull ?? [];
final catsByGroup = ref.watch(categoriesByGroupProvider);
final now = DateTime.now();
final months = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
final monthLabel = '${months[now.month]} ${now.year}';
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 90),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Budget du mois', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(monthLabel, style: Theme.of(context).textTheme.bodySmall),
],
),
),
),
const SizedBox(height: 16),
...groups.map((group) {
final cats = catsByGroup[group.id] ?? [];
if (cats.isEmpty) return const SizedBox.shrink();
return _CategoryGroupTile(group: group, categoryIds: cats.map((c) => c.id).toList());
}),
],
);
}
}
class _CategoryGroupTile extends ConsumerWidget {
final CategoryGroup group;
final List<int> categoryIds;
const _CategoryGroupTile({required this.group, required this.categoryIds});
@override
Widget build(BuildContext context, WidgetRef ref) {
final expanded = ref.watch(_expandedGroupProvider(group.id));
final cats = ref.watch(categoriesByGroupProvider)[group.id] ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
expanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(group.name,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.w600)),
trailing: Text(
formatCurrency(
cats.fold(0.0, (s, c) => s + ref.watch(categoryRemainingProvider(c.id))),
),
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () => ref.read(_expandedGroupProvider(group.id).notifier).toggle(),
),
if (expanded)
...cats.map((c) => _CategoryRow(categoryId: c.id)),
const Divider(height: 1),
],
);
}
}
class _CategoryRow extends ConsumerWidget {
final int categoryId;
const _CategoryRow({required this.categoryId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cats = ref.watch(categoriesByGroupProvider);
final category = cats.values
.expand((list) => list)
.firstWhere((c) => c.id == categoryId, orElse: () => throw StateError('missing'));
final budgeted = ref.watch(monthlyBudgetProvider(categoryId)) ?? 0.0;
final spent = ref.watch(categorySpentProvider(categoryId));
final remaining = ref.watch(categoryRemainingProvider(categoryId));
final pct = budgeted > 0 ? ((budgeted + spent) / budgeted).clamp(0.0, 1.0) : 0.0;
void _editBudget() async {
final month = ref.read(currentMonthProvider);
final db = ref.read(dbProvider);
final ctrl = TextEditingController(text: budgeted > 0 ? budgeted.toStringAsFixed(2) : '');
final newAmount = await showDialog<double>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Budget : ${category.name}'),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(labelText: 'Montant', hintText: '0,00'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')),
FilledButton(
onPressed: () {
final v = double.tryParse(ctrl.text.trim().replaceAll(',', '.'));
if (v != null) Navigator.pop(ctx, v);
},
child: const Text('Enregistrer'),
),
],
),
);
ctrl.dispose();
if (newAmount == null) return;
final existing = await (db.select(db.budgets)
..where((b) => b.categoryId.equals(categoryId))
..where((b) => b.month.equals(month)))
.get();
if (existing.isNotEmpty) {
await db.update(db.budgets).replace(existing.first.copyWith(budgeted: newAmount));
} else {
await db.into(db.budgets).insert(BudgetsCompanion.insert(
categoryId: categoryId,
month: month,
budgeted: Value(newAmount),
));
}
}
return Padding(
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
title: Text(category.name, style: Theme.of(context).textTheme.bodyMedium),
trailing: Text(
formatCurrency(remaining),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: remaining >= 0 ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.error,
),
),
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct,
backgroundColor: Theme.of(context).colorScheme.outlineVariant,
valueColor: AlwaysStoppedAnimation<Color>(
remaining >= 0 ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.error,
),
minHeight: 4,
),
),
const SizedBox(height: 2),
Row(
children: [
GestureDetector(
onTap: _editBudget,
child: Text('Budget: ${formatCurrency(budgeted)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
decoration: TextDecoration.underline,
color: Theme.of(context).colorScheme.primary,
)),
),
const SizedBox(width: 12),
Text('Dépensé: ${formatCurrency(spent.abs())}',
style: Theme.of(context).textTheme.bodySmall),
],
),
],
),
),
),
);
}
}
class _ExpandedGroupNotifier extends StateNotifier<bool> {
_ExpandedGroupNotifier() : super(true);
void toggle() => state = !state;
}
final _expandedGroupProvider =
StateNotifierProvider.family<_ExpandedGroupNotifier, bool, int>(
(ref, id) => _ExpandedGroupNotifier());