diff --git a/mobile/README.md b/mobile/README.md index 5221d2e..4736c28 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -43,7 +43,7 @@ lib/ - [x] Logique score (plancher 0, delta réel) - [x] Onboarding sans compte (4 étapes) - [x] Tableau de bord minimal -- [ ] Espace parent (PIN, grille règles) +- [x] Espace parent (PIN, grille règles, historique, annulation) - [ ] Fiche enfant + boutique - [ ] Export/import, IAP, AdMob diff --git a/mobile/lib/data/database/app_database.dart b/mobile/lib/data/database/app_database.dart index fbed6a5..b1332ce 100644 --- a/mobile/lib/data/database/app_database.dart +++ b/mobile/lib/data/database/app_database.dart @@ -5,6 +5,7 @@ import 'package:drift/native.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../models/movement_detail.dart'; import '../seed/default_rewards.dart'; import '../seed/default_rules.dart'; import 'tables.dart'; @@ -137,6 +138,83 @@ class AppDatabase extends _$AppDatabase { Future getPinHash() => getConfig('pin_hash'); + Future> listActiveRules() { + return (select(regles) + ..where((r) => r.actif.equals(true)) + ..orderBy([ + (r) => OrderingTerm.asc(r.familleOrdre), + (r) => OrderingTerm.asc(r.ordre), + ])) + .get(); + } + + Future> listRecentMovements({int limit = 30}) async { + final query = select(mouvements).join([ + innerJoin(enfants, enfants.id.equalsExp(mouvements.enfantId)), + leftOuterJoin(regles, regles.id.equalsExp(mouvements.regleId)), + leftOuterJoin( + recompenses, + recompenses.id.equalsExp(mouvements.recompenseId), + ), + ]) + ..where(mouvements.annule.equals(false)) + ..orderBy([ + OrderingTerm.desc(mouvements.creeLe), + OrderingTerm.desc(mouvements.id), + ]) + ..limit(limit); + + final rows = await query.get(); + return rows.map((row) { + final movement = row.readTable(mouvements); + final child = row.readTable(enfants); + final rule = row.readTableOrNull(regles); + final reward = row.readTableOrNull(recompenses); + + return MovementDetail( + id: movement.id, + childId: child.id, + childName: child.prenom, + delta: movement.delta, + createdAt: movement.creeLe, + note: movement.note, + ruleLabel: rule?.libelle, + ruleIcon: rule?.icone, + rewardLabel: reward?.libelle, + rewardIcon: reward?.icone, + ); + }).toList(); + } + + /// Annule un mouvement : inverse le delta (plancher 0). + Future cancelMovement(int movementId) async { + return transaction(() async { + final movement = await (select(mouvements) + ..where((m) => m.id.equals(movementId) & m.annule.equals(false))) + .getSingleOrNull(); + + if (movement == null) return null; + + final child = await (select(enfants) + ..where((e) => e.id.equals(movement.enfantId))) + .getSingleOrNull(); + + if (child == null) return null; + + final correction = -movement.delta; + final newScore = (child.score + correction).clamp(0, 1 << 30); + + await (update(mouvements)..where((m) => m.id.equals(movementId))) + .write(const MouvementsCompanion(annule: Value(true))); + + await (update(enfants)..where((e) => e.id.equals(child.id))) + .write(EnfantsCompanion(score: Value(newScore))); + + return (select(enfants)..where((e) => e.id.equals(child.id))) + .getSingleOrNull(); + }); + } + /// Applique une règle : plancher score à 0, delta réel enregistré. Future applyRule(int childId, int ruleId, {String? note}) async { return transaction(() async { diff --git a/mobile/lib/data/models/movement_detail.dart b/mobile/lib/data/models/movement_detail.dart new file mode 100644 index 0000000..2509a29 --- /dev/null +++ b/mobile/lib/data/models/movement_detail.dart @@ -0,0 +1,30 @@ +class MovementDetail { + const MovementDetail({ + required this.id, + required this.childId, + required this.childName, + required this.delta, + required this.createdAt, + this.note, + this.ruleLabel, + this.ruleIcon, + this.rewardLabel, + this.rewardIcon, + }); + + final int id; + final int childId; + final String childName; + final int delta; + final DateTime createdAt; + final String? note; + final String? ruleLabel; + final String? ruleIcon; + final String? rewardLabel; + final String? rewardIcon; + + String get label => + rewardLabel ?? ruleLabel ?? note ?? 'Mouvement'; + + String get icon => rewardIcon ?? ruleIcon ?? '📋'; +} diff --git a/mobile/lib/data/repositories/parent_repository.dart b/mobile/lib/data/repositories/parent_repository.dart new file mode 100644 index 0000000..6ddaa37 --- /dev/null +++ b/mobile/lib/data/repositories/parent_repository.dart @@ -0,0 +1,30 @@ +import 'package:bcrypt/bcrypt.dart'; + +import '../database/app_database.dart'; +import '../models/movement_detail.dart'; + +class ParentRepository { + ParentRepository(this._db); + + final AppDatabase _db; + + Future verifyPin(String pin) async { + final hash = await _db.getPinHash(); + if (hash == null) return false; + return BCrypt.checkpw(pin, hash); + } + + Future> listRules() => _db.listActiveRules(); + + Future> listMovements({int limit = 30}) { + return _db.listRecentMovements(limit: limit); + } + + Future applyRule(int childId, int ruleId) { + return _db.applyRule(childId, ruleId); + } + + Future cancelMovement(int movementId) { + return _db.cancelMovement(movementId); + } +} diff --git a/mobile/lib/features/dashboard/dashboard_screen.dart b/mobile/lib/features/dashboard/dashboard_screen.dart index 289d6e9..bbff309 100644 --- a/mobile/lib/features/dashboard/dashboard_screen.dart +++ b/mobile/lib/features/dashboard/dashboard_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/app_theme.dart'; +import '../../features/parent/parent_gate_screen.dart'; import '../../providers/database_provider.dart'; class DashboardScreen extends ConsumerWidget { @@ -22,9 +23,9 @@ class DashboardScreen extends ConsumerWidget { icon: const Icon(Icons.lock_outline), tooltip: 'Espace parent', onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Espace parent — bientôt disponible'), + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ParentGateScreen(), ), ); }, diff --git a/mobile/lib/features/parent/parent_gate_screen.dart b/mobile/lib/features/parent/parent_gate_screen.dart new file mode 100644 index 0000000..433571a --- /dev/null +++ b/mobile/lib/features/parent/parent_gate_screen.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../providers/parent_providers.dart'; +import 'parent_space_screen.dart'; +import 'widgets/pin_entry_card.dart'; + +/// Verrouillage en mémoire : le PIN est redemandé à chaque visite. +class ParentGateScreen extends ConsumerStatefulWidget { + const ParentGateScreen({super.key}); + + @override + ConsumerState createState() => _ParentGateScreenState(); +} + +class _ParentGateScreenState extends ConsumerState { + final _pinController = TextEditingController(); + bool _obscurePin = true; + bool _isChecking = false; + String? _error; + + @override + void dispose() { + _pinController.dispose(); + super.dispose(); + } + + Future _submitPin() async { + final pin = _pinController.text; + if (pin.length < 4) { + setState(() => _error = 'Le PIN doit contenir au moins 4 chiffres.'); + return; + } + + setState(() { + _isChecking = true; + _error = null; + }); + + final ok = await ref.read(parentRepositoryProvider).verifyPin(pin); + if (!mounted) return; + + if (!ok) { + setState(() { + _isChecking = false; + _error = 'Code PIN incorrect.'; + _pinController.clear(); + }); + return; + } + + _pinController.clear(); + await Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const ParentSpaceScreen()), + ); + } + + @override + Widget build(BuildContext context) { + 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 Text('Espace Parents'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: PinEntryCard( + pinController: _pinController, + obscurePin: _obscurePin, + isLoading: _isChecking, + error: _error, + onToggleObscure: () => setState(() => _obscurePin = !_obscurePin), + onSubmit: _submitPin, + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/parent/parent_space_screen.dart b/mobile/lib/features/parent/parent_space_screen.dart new file mode 100644 index 0000000..e189594 --- /dev/null +++ b/mobile/lib/features/parent/parent_space_screen.dart @@ -0,0 +1,360 @@ +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 createState() => _ParentSpaceScreenState(); +} + +class _ParentSpaceScreenState extends ConsumerState { + int? _selectedChildId; + bool _isApplying = false; + + void _refreshData() { + ref.invalidate(childrenProvider); + ref.invalidate(movementsProvider); + } + + Future _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 _confirmCancel(MovementDetail movement) async { + final sign = movement.delta > 0 ? '+' : ''; + final confirmed = await showDialog( + 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 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)}'; + } +} diff --git a/mobile/lib/features/parent/widgets/child_selector.dart b/mobile/lib/features/parent/widgets/child_selector.dart new file mode 100644 index 0000000..c18e8e0 --- /dev/null +++ b/mobile/lib/features/parent/widgets/child_selector.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../data/database/app_database.dart'; + +class ChildSelector extends StatelessWidget { + const ChildSelector({ + super.key, + required this.children, + required this.selectedId, + required this.onSelected, + }); + + final List children; + final int selectedId; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: children.map((child) { + final selected = child.id == selectedId; + final color = AppTheme.parseColor(child.couleur); + + return Padding( + padding: const EdgeInsets.only(right: 12), + child: InkWell( + onTap: () => onSelected(child.id), + borderRadius: BorderRadius.circular(16), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: selected ? color : Colors.transparent, + width: 3, + ), + ), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + CircleAvatar( + radius: 32, + backgroundColor: color, + child: Text( + child.prenom[0].toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + Positioned( + right: -4, + bottom: -4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color), + ), + child: Text( + '${child.score}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + fontSize: 12, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + child.prenom, + style: TextStyle( + fontSize: 12, + fontWeight: + selected ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/mobile/lib/features/parent/widgets/pin_entry_card.dart b/mobile/lib/features/parent/widgets/pin_entry_card.dart new file mode 100644 index 0000000..698b22d --- /dev/null +++ b/mobile/lib/features/parent/widgets/pin_entry_card.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class PinEntryCard extends StatelessWidget { + const PinEntryCard({ + super.key, + required this.pinController, + required this.obscurePin, + required this.isLoading, + required this.onToggleObscure, + required this.onSubmit, + this.error, + }); + + final TextEditingController pinController; + final bool obscurePin; + final bool isLoading; + final VoidCallback onToggleObscure; + final VoidCallback onSubmit; + final String? error; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircleAvatar( + radius: 36, + backgroundColor: Color(0xFF667EEA), + child: Icon(Icons.family_restroom, color: Colors.white, size: 36), + ), + const SizedBox(height: 16), + Text( + 'Entrez votre code PIN', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + TextField( + controller: pinController, + obscureText: obscurePin, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24, letterSpacing: 8), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(8), + ], + decoration: InputDecoration( + hintText: '••••', + suffixIcon: IconButton( + icon: Icon( + obscurePin ? Icons.visibility : Icons.visibility_off, + ), + onPressed: onToggleObscure, + ), + ), + onSubmitted: (_) => onSubmit(), + ), + if (error != null) ...[ + const SizedBox(height: 12), + Text( + error!, + style: TextStyle(color: Colors.red.shade700), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: isLoading ? null : onSubmit, + child: isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Connexion'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/parent/widgets/rules_grid.dart b/mobile/lib/features/parent/widgets/rules_grid.dart new file mode 100644 index 0000000..f3d81c4 --- /dev/null +++ b/mobile/lib/features/parent/widgets/rules_grid.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import '../../../data/database/app_database.dart'; + +class RulesGrid extends StatelessWidget { + const RulesGrid({ + super.key, + required this.rules, + required this.isApplying, + required this.onRuleTap, + }); + + final List rules; + final bool isApplying; + final void Function(int ruleId) onRuleTap; + + @override + Widget build(BuildContext context) { + final groups = >{}; + for (final rule in rules) { + groups.putIfAbsent(rule.famille, () => []).add(rule); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: groups.entries.map((entry) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + entry.key, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: entry.value.map((rule) { + final positive = rule.points >= 0; + return Material( + color: positive + ? Colors.green.shade50 + : Colors.red.shade50, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: isApplying ? null : () => onRuleTap(rule.id), + borderRadius: BorderRadius.circular(12), + child: Container( + width: 160, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: positive + ? Colors.green.shade200 + : Colors.red.shade200, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${rule.icone} ${rule.libelle}', + style: const TextStyle(fontSize: 13), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '${rule.points > 0 ? '+' : ''}${rule.points}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: positive + ? Colors.green.shade800 + : Colors.red.shade800, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ); + }).toList(), + ); + } +} diff --git a/mobile/lib/providers/parent_providers.dart b/mobile/lib/providers/parent_providers.dart new file mode 100644 index 0000000..cfd78b4 --- /dev/null +++ b/mobile/lib/providers/parent_providers.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/database/app_database.dart'; +import '../data/models/movement_detail.dart'; +import '../data/repositories/parent_repository.dart'; +import 'database_provider.dart'; + +final parentRepositoryProvider = Provider((ref) { + return ParentRepository(ref.watch(databaseProvider)); +}); + +final rulesProvider = FutureProvider>((ref) async { + final repo = ref.watch(parentRepositoryProvider); + return repo.listRules(); +}); + +final movementsProvider = FutureProvider>((ref) async { + final repo = ref.watch(parentRepositoryProvider); + return repo.listMovements(); +}); diff --git a/mobile/test/cancel_movement_test.dart b/mobile/test/cancel_movement_test.dart new file mode 100644 index 0000000..054e80f --- /dev/null +++ b/mobile/test/cancel_movement_test.dart @@ -0,0 +1,51 @@ +import 'package:bonpoint/data/database/app_database.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase db; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + await db.importDefaultPack(); + }); + + tearDown(() async { + await db.close(); + }); + + test('annulerMouvement inverse le delta avec plancher à 0', () async { + final childId = await db.insertChild( + prenom: 'Léa', + couleur: '#6C63FF', + ); + + final rules = await db.listActiveRules(); + final positiveRule = rules.firstWhere((r) => r.points == 1); + final negativeRule = rules.firstWhere((r) => r.points == -2); + + await db.applyRule(childId, positiveRule.id); + await db.applyRule(childId, negativeRule.id); + + final movements = await db.select(db.mouvements).get(); + expect(movements.length, 2); + + final childBefore = await (db.select(db.enfants) + ..where((e) => e.id.equals(childId))) + .getSingle(); + expect(childBefore.score, 0); + + // Annuler le mouvement négatif (delta réel -1) → score remonte à 1 + final negativeMovement = + movements.firstWhere((m) => m.delta < 0); + await db.cancelMovement(negativeMovement.id); + + final childAfter = await (db.select(db.enfants) + ..where((e) => e.id.equals(childId))) + .getSingle(); + expect(childAfter.score, 1); + + final remaining = await db.listRecentMovements(); + expect(remaining.length, 1); + }); +}