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
co-authored by Cursor
parent aac3a729a2
commit b8faf3b4df
12 changed files with 950 additions and 4 deletions
@@ -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(),
),
);
}
}
@@ -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'),
),
),
],
),
),
);
}
}
@@ -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(),
);
}
}