ynab_lite/lib/screens/budget_screen.dart

171 lines
5.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/db_provider.dart';
import '../theme/app_theme.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);
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('Juillet 2026', 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;
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: [
Text('Budget: ${formatCurrency(budgeted)}',
style: Theme.of(context).textTheme.bodySmall),
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());