bonpoint/mobile/lib/features/parent/parent_space_screen.dart
Julien Martin b8faf3b4df Ajoute l'espace parent : PIN, grille de règles, historique et annulation.
Phase 2 du MVP Flutter : vérification PIN locale, application des règles par famille, suivi des mouvements et correction avec plancher à 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 12:24:42 +02:00

361 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../data/database/app_database.dart';
import '../../data/models/movement_detail.dart';
import '../../providers/database_provider.dart';
import '../../providers/parent_providers.dart';
import 'widgets/child_selector.dart';
import 'widgets/rules_grid.dart';
class ParentSpaceScreen extends ConsumerStatefulWidget {
const ParentSpaceScreen({super.key});
@override
ConsumerState<ParentSpaceScreen> createState() => _ParentSpaceScreenState();
}
class _ParentSpaceScreenState extends ConsumerState<ParentSpaceScreen> {
int? _selectedChildId;
bool _isApplying = false;
void _refreshData() {
ref.invalidate(childrenProvider);
ref.invalidate(movementsProvider);
}
Future<void> _applyRule(int ruleId) async {
final childId = _selectedChildId;
if (childId == null || _isApplying) return;
setState(() => _isApplying = true);
try {
final child = await ref
.read(parentRepositoryProvider)
.applyRule(childId, ruleId);
if (!mounted) return;
if (child == null) {
_showFeedback('Erreur lors de l\'application.');
return;
}
_refreshData();
_showFeedback('${child.prenom} : ${child.score} pts !');
} finally {
if (mounted) setState(() => _isApplying = false);
}
}
Future<void> _confirmCancel(MovementDetail movement) async {
final sign = movement.delta > 0 ? '+' : '';
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Annuler ce mouvement ?'),
content: Text(
'Annuler « ${movement.label} » pour ${movement.childName} '
'($sign${movement.delta}) ?\nLes points seront recalculés.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Non'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Oui, annuler'),
),
],
),
);
if (confirmed != true || !mounted) return;
final result = await ref
.read(parentRepositoryProvider)
.cancelMovement(movement.id);
if (!mounted) return;
if (result == null) {
_showFeedback('Impossible d\'annuler.');
return;
}
_refreshData();
_showFeedback('Mouvement annulé');
}
void _showFeedback(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}
@override
Widget build(BuildContext context) {
final childrenAsync = ref.watch(childrenProvider);
final rulesAsync = ref.watch(rulesProvider);
final movementsAsync = ref.watch(movementsProvider);
return DecoratedBox(
decoration: const BoxDecoration(gradient: AppTheme.primaryGradient),
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
title: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.family_restroom, size: 22),
SizedBox(width: 8),
Text('Espace Parents'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Déconnexion'),
),
],
),
body: childrenAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
error: (_, _) => const Center(
child: Text('Erreur', style: TextStyle(color: Colors.white)),
),
data: (children) {
if (children.isEmpty) {
return const Center(
child: Text(
'Aucun enfant',
style: TextStyle(color: Colors.white),
),
);
}
_selectedChildId ??= children.first.id;
final selected = children.firstWhere(
(c) => c.id == _selectedChildId,
orElse: () => children.first,
);
return RefreshIndicator(
onRefresh: () async => _refreshData(),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
children: [
_SectionCard(
title: 'Enfant sélectionné',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ChildSelector(
children: children,
selectedId: selected.id,
onSelected: (id) =>
setState(() => _selectedChildId = id),
),
const SizedBox(height: 12),
_ScoreBanner(child: selected),
],
),
),
const SizedBox(height: 12),
_SectionCard(
title: 'Appliquer une règle',
child: rulesAsync.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
),
),
error: (_, _) => const Text('Erreur de chargement'),
data: (rules) {
if (rules.isEmpty) {
return const Text(
'Aucune règle active. Importez le pack par défaut '
'ou ajoutez des règles.',
);
}
return RulesGrid(
rules: rules,
isApplying: _isApplying,
onRuleTap: _applyRule,
);
},
),
),
const SizedBox(height: 12),
_SectionCard(
title: 'Derniers mouvements',
child: movementsAsync.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
),
),
error: (_, _) => const Text('Erreur de chargement'),
data: (movements) => _MovementHistory(
movements: movements,
onCancel: _confirmCancel,
),
),
),
],
),
);
},
),
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF667EEA),
),
),
const SizedBox(height: 12),
child,
],
),
),
);
}
}
class _ScoreBanner extends StatelessWidget {
const _ScoreBanner({required this.child});
final Enfant child;
@override
Widget build(BuildContext context) {
final color = AppTheme.parseColor(child.couleur);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border.all(color: color, width: 2),
borderRadius: BorderRadius.circular(12),
color: color.withValues(alpha: 0.08),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
child.prenom,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
Text(
'${child.score} points',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: color,
),
),
],
),
);
}
}
class _MovementHistory extends StatelessWidget {
const _MovementHistory({
required this.movements,
required this.onCancel,
});
final List<MovementDetail> movements;
final void Function(MovementDetail) onCancel;
@override
Widget build(BuildContext context) {
if (movements.isEmpty) {
return Text(
'Aucun mouvement',
style: TextStyle(color: Colors.grey.shade600),
);
}
return Column(
children: movements.map((m) {
final sign = m.delta > 0 ? '+' : '';
final deltaColor =
m.delta >= 0 ? Colors.green.shade700 : Colors.red.shade700;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
'${m.icon} ${m.childName}${m.label}',
style: const TextStyle(fontSize: 14),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$sign${m.delta}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: deltaColor,
),
),
Text(
_formatDate(m.createdAt),
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
),
IconButton(
icon: const Icon(Icons.close, size: 20),
color: Colors.grey.shade600,
tooltip: 'Annuler',
onPressed: () => onCancel(m),
),
],
),
);
}).toList(),
);
}
static String _formatDate(DateTime dt) {
final local = dt.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${two(local.day)}/${two(local.month)} '
'${two(local.hour)}:${two(local.minute)}';
}
}