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>
361 lines
11 KiB
Dart
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)}';
|
|
}
|
|
}
|