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:
parent
aac3a729a2
commit
b8faf3b4df
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
30
mobile/lib/data/models/movement_detail.dart
Normal file
30
mobile/lib/data/models/movement_detail.dart
Normal 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 ?? '📋';
|
||||||
|
}
|
||||||
30
mobile/lib/data/repositories/parent_repository.dart
Normal file
30
mobile/lib/data/repositories/parent_repository.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
88
mobile/lib/features/parent/parent_gate_screen.dart
Normal file
88
mobile/lib/features/parent/parent_gate_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
360
mobile/lib/features/parent/parent_space_screen.dart
Normal file
360
mobile/lib/features/parent/parent_space_screen.dart
Normal 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)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
102
mobile/lib/features/parent/widgets/child_selector.dart
Normal file
102
mobile/lib/features/parent/widgets/child_selector.dart
Normal 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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
mobile/lib/features/parent/widgets/pin_entry_card.dart
Normal file
91
mobile/lib/features/parent/widgets/pin_entry_card.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
mobile/lib/features/parent/widgets/rules_grid.dart
Normal file
95
mobile/lib/features/parent/widgets/rules_grid.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
mobile/lib/providers/parent_providers.dart
Normal file
20
mobile/lib/providers/parent_providers.dart
Normal 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();
|
||||||
|
});
|
||||||
51
mobile/test/cancel_movement_test.dart
Normal file
51
mobile/test/cancel_movement_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user