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>
This commit is contained in:
Julien Martin 2026-06-12 12:24:42 +02:00
parent aac3a729a2
commit b8faf3b4df
12 changed files with 950 additions and 4 deletions

View File

@ -43,7 +43,7 @@ lib/
- [x] Logique score (plancher 0, delta réel) - [x] Logique score (plancher 0, delta réel)
- [x] Onboarding sans compte (4 étapes) - [x] Onboarding sans compte (4 étapes)
- [x] Tableau de bord minimal - [x] Tableau de bord minimal
- [ ] Espace parent (PIN, grille règles) - [x] Espace parent (PIN, grille règles, historique, annulation)
- [ ] Fiche enfant + boutique - [ ] Fiche enfant + boutique
- [ ] Export/import, IAP, AdMob - [ ] Export/import, IAP, AdMob

View File

@ -5,6 +5,7 @@ import 'package:drift/native.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../models/movement_detail.dart';
import '../seed/default_rewards.dart'; import '../seed/default_rewards.dart';
import '../seed/default_rules.dart'; import '../seed/default_rules.dart';
import 'tables.dart'; import 'tables.dart';
@ -137,6 +138,83 @@ class AppDatabase extends _$AppDatabase {
Future<String?> getPinHash() => getConfig('pin_hash'); Future<String?> getPinHash() => getConfig('pin_hash');
Future<List<Regle>> listActiveRules() {
return (select(regles)
..where((r) => r.actif.equals(true))
..orderBy([
(r) => OrderingTerm.asc(r.familleOrdre),
(r) => OrderingTerm.asc(r.ordre),
]))
.get();
}
Future<List<MovementDetail>> 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<Enfant?> 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é. /// Applique une règle : plancher score à 0, delta réel enregistré.
Future<Enfant?> applyRule(int childId, int ruleId, {String? note}) async { Future<Enfant?> applyRule(int childId, int ruleId, {String? note}) async {
return transaction(() async { return transaction(() async {

View File

@ -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 ?? '📋';
}

View File

@ -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<bool> verifyPin(String pin) async {
final hash = await _db.getPinHash();
if (hash == null) return false;
return BCrypt.checkpw(pin, hash);
}
Future<List<Regle>> listRules() => _db.listActiveRules();
Future<List<MovementDetail>> listMovements({int limit = 30}) {
return _db.listRecentMovements(limit: limit);
}
Future<Enfant?> applyRule(int childId, int ruleId) {
return _db.applyRule(childId, ruleId);
}
Future<Enfant?> cancelMovement(int movementId) {
return _db.cancelMovement(movementId);
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../features/parent/parent_gate_screen.dart';
import '../../providers/database_provider.dart'; import '../../providers/database_provider.dart';
class DashboardScreen extends ConsumerWidget { class DashboardScreen extends ConsumerWidget {
@ -22,9 +23,9 @@ class DashboardScreen extends ConsumerWidget {
icon: const Icon(Icons.lock_outline), icon: const Icon(Icons.lock_outline),
tooltip: 'Espace parent', tooltip: 'Espace parent',
onPressed: () { onPressed: () {
ScaffoldMessenger.of(context).showSnackBar( Navigator.of(context).push(
const SnackBar( MaterialPageRoute<void>(
content: Text('Espace parent — bientôt disponible'), builder: (_) => const ParentGateScreen(),
), ),
); );
}, },

View File

@ -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<ParentGateScreen> createState() => _ParentGateScreenState();
}
class _ParentGateScreenState extends ConsumerState<ParentGateScreen> {
final _pinController = TextEditingController();
bool _obscurePin = true;
bool _isChecking = false;
String? _error;
@override
void dispose() {
_pinController.dispose();
super.dispose();
}
Future<void> _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<void>(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,
),
),
),
),
);
}
}

View File

@ -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<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)}';
}
}

View File

@ -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<Enfant> children;
final int selectedId;
final ValueChanged<int> 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(),
),
);
}
}

View File

@ -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'),
),
),
],
),
),
);
}
}

View File

@ -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<Regle> rules;
final bool isApplying;
final void Function(int ruleId) onRuleTap;
@override
Widget build(BuildContext context) {
final groups = <String, List<Regle>>{};
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(),
);
}
}

View File

@ -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<ParentRepository>((ref) {
return ParentRepository(ref.watch(databaseProvider));
});
final rulesProvider = FutureProvider<List<Regle>>((ref) async {
final repo = ref.watch(parentRepositoryProvider);
return repo.listRules();
});
final movementsProvider = FutureProvider<List<MovementDetail>>((ref) async {
final repo = ref.watch(parentRepositoryProvider);
return repo.listMovements();
});

View File

@ -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);
});
}