227 lines
8.1 KiB
Dart
227 lines
8.1 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: [
|
|
InkWell(
|
|
onTap: () => ref.read(_expandedGroupProvider(group.id).notifier).toggle(),
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
expanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right,
|
|
size: 18,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(group.name,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.w600)),
|
|
const Spacer(),
|
|
Text(
|
|
formatCurrency(
|
|
cats.fold(0.0, (s, c) => s + ref.watch(categoryRemainingProvider(c.id))),
|
|
),
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(category.name, style: Theme.of(context).textTheme.bodyMedium),
|
|
),
|
|
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());
|