Initialise l'app Flutter mobile : BDD Drift, onboarding sans compte et tableau de bord.

Phase 1 du MVP : schéma SQLite porté depuis le prototype web, seed des règles/récompenses, flux d'onboarding en 4 étapes et écran d'accueil minimal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Julien Martin
2026-06-12 12:12:01 +02:00
co-authored by Cursor
parent a82dffe959
commit aac3a729a2
45 changed files with 6328 additions and 4 deletions
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/theme/app_theme.dart';
import 'features/dashboard/dashboard_screen.dart';
import 'features/onboarding/onboarding_flow.dart';
import 'providers/database_provider.dart';
class BonpointApp extends ConsumerStatefulWidget {
const BonpointApp({super.key});
@override
ConsumerState<BonpointApp> createState() => _BonpointAppState();
}
class _BonpointAppState extends ConsumerState<BonpointApp> {
@override
void initState() {
super.initState();
Future.microtask(() => ref.read(databaseProvider).initialize());
}
@override
Widget build(BuildContext context) {
final onboardingAsync = ref.watch(onboardingCompleteProvider);
return MaterialApp(
title: 'Bons Points',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
home: onboardingAsync.when(
loading: () => const _SplashScreen(),
error: (_, _) => const OnboardingFlow(),
data: (complete) =>
complete ? const DashboardScreen() : const OnboardingFlow(),
),
);
}
}
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(gradient: AppTheme.primaryGradient),
child: Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
Text(
'Bons Points',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
const CircularProgressIndicator(color: Colors.white),
],
),
),
),
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
/// Charte inspirée du prototype web (dégradé violet, cartes blanches).
abstract final class AppTheme {
static const primaryGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF667EEA), Color(0xFF764BA2)],
);
static const childColors = [
'#6C63FF',
'#E91E8C',
'#2196F3',
'#FF9800',
'#4CAF50',
'#9C27B0',
'#00BCD4',
'#F44336',
];
static Color parseColor(String hex) {
final value = hex.replaceFirst('#', '');
return Color(int.parse('FF$value', radix: 16));
}
static ThemeData light() {
final seed = parseColor('#667EEA');
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
),
scaffoldBackgroundColor: seed,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.3)),
),
),
cardTheme: CardThemeData(
color: Colors.white,
elevation: 4,
shadowColor: Colors.black26,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: seed,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: Colors.white),
),
);
}
}
+178
View File
@@ -0,0 +1,178 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../seed/default_rewards.dart';
import '../seed/default_rules.dart';
import 'tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [Enfants, Regles, Mouvements, Recompenses, Config])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
},
onUpgrade: (m, from, to) async {},
);
/// Met à jour les packs par défaut si l'utilisateur les a déjà importés.
Future<void> initialize() async {
if (await getConfig('regles_version') != null) {
await _syncDefaultRules();
}
if (await getConfig('recompenses_version') != null) {
await _syncDefaultRewards();
}
}
Future<void> importDefaultPack() async {
await _syncDefaultRules();
await _syncDefaultRewards();
}
Future<void> _syncDefaultRules() async {
final version = await getConfig('regles_version');
if (version == rulesVersion) return;
await (update(regles)..where((r) => r.actif.equals(true)))
.write(const ReglesCompanion(actif: Value(false)));
for (final rule in defaultRules) {
await into(regles).insert(
ReglesCompanion.insert(
libelle: rule.libelle,
points: rule.points,
icone: Value(rule.icone),
ordre: Value(rule.ordre),
famille: Value(rule.famille),
familleOrdre: Value(rule.familleOrdre),
),
);
}
await setConfig('regles_version', rulesVersion);
}
Future<void> _syncDefaultRewards() async {
final version = await getConfig('recompenses_version');
if (version == rewardsVersion) return;
await (update(recompenses)..where((r) => r.actif.equals(true)))
.write(const RecompensesCompanion(actif: Value(false)));
for (final reward in defaultRewards) {
await into(recompenses).insert(
RecompensesCompanion.insert(
libelle: reward.libelle,
coutPoints: reward.coutPoints,
icone: Value(reward.icone),
ordre: Value(reward.ordre),
),
);
}
await setConfig('recompenses_version', rewardsVersion);
}
Future<String?> getConfig(String key) async {
final row = await (select(config)..where((c) => c.cle.equals(key)))
.getSingleOrNull();
return row?.valeur;
}
Future<void> setConfig(String key, String value) async {
await into(config).insertOnConflictUpdate(
ConfigCompanion.insert(cle: key, valeur: value),
);
}
Future<bool> isOnboardingComplete() async {
return await getConfig('onboarding_complete') == 'true';
}
Future<void> completeOnboarding() async {
await setConfig('onboarding_complete', 'true');
}
Future<List<Enfant>> listChildren() {
return (select(enfants)..orderBy([(e) => OrderingTerm.asc(e.ordre)])).get();
}
Future<int> countChildren() async {
final count = await enfants.count().getSingle();
return count;
}
Future<int> insertChild({
required String prenom,
required String couleur,
String? dateNaissance,
}) {
return into(enfants).insert(
EnfantsCompanion.insert(
prenom: prenom,
couleur: Value(couleur),
dateNaissance: Value(dateNaissance),
ordre: const Value(1),
),
);
}
Future<void> setPinHash(String hash) async {
await setConfig('pin_hash', hash);
}
Future<String?> getPinHash() => getConfig('pin_hash');
/// Applique une règle : plancher score à 0, delta réel enregistré.
Future<Enfant?> applyRule(int childId, int ruleId, {String? note}) async {
return transaction(() async {
final child = await (select(enfants)..where((e) => e.id.equals(childId)))
.getSingleOrNull();
final rule = await (select(regles)
..where((r) => r.id.equals(ruleId) & r.actif.equals(true)))
.getSingleOrNull();
if (child == null || rule == null) return null;
final newScore = (child.score + rule.points).clamp(0, 1 << 30);
final realDelta = newScore - child.score;
await into(mouvements).insert(
MouvementsCompanion.insert(
enfantId: childId,
regleId: Value(ruleId),
delta: realDelta,
note: Value(note),
),
);
await (update(enfants)..where((e) => e.id.equals(childId)))
.write(EnfantsCompanion(score: Value(newScore)));
return (select(enfants)..where((e) => e.id.equals(childId)))
.getSingleOrNull();
});
}
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'bonpoint.db'));
return NativeDatabase.createInBackground(file);
});
}
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
import 'package:drift/drift.dart';
class Enfants extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get prenom => text()();
TextColumn get dateNaissance => text().nullable()();
IntColumn get score => integer().withDefault(const Constant(0))();
TextColumn get couleur => text().withDefault(const Constant('#6C63FF'))();
IntColumn get ordre => integer().withDefault(const Constant(0))();
@override
List<Set<Column<Object>>>? get uniqueKeys => [
{prenom},
];
}
class Regles extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get libelle => text()();
IntColumn get points => integer()();
TextColumn get icone => text().withDefault(const Constant(''))();
BoolColumn get actif => boolean().withDefault(const Constant(true))();
IntColumn get ordre => integer().withDefault(const Constant(0))();
TextColumn get famille => text().withDefault(const Constant('📋 Autre'))();
IntColumn get familleOrdre => integer().withDefault(const Constant(99))();
}
class Mouvements extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get enfantId => integer().references(Enfants, #id)();
IntColumn get regleId => integer().nullable().references(Regles, #id)();
IntColumn get recompenseId => integer().nullable().references(Recompenses, #id)();
IntColumn get delta => integer()();
TextColumn get note => text().nullable()();
DateTimeColumn get creeLe => dateTime().withDefault(currentDateAndTime)();
BoolColumn get annule => boolean().withDefault(const Constant(false))();
}
class Recompenses extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get libelle => text()();
IntColumn get coutPoints => integer()();
TextColumn get icone => text().withDefault(const Constant('🎁'))();
BoolColumn get actif => boolean().withDefault(const Constant(true))();
IntColumn get ordre => integer().withDefault(const Constant(0))();
}
class Config extends Table {
TextColumn get cle => text()();
TextColumn get valeur => text()();
@override
Set<Column<Object>>? get primaryKey => {cle};
}
@@ -0,0 +1,29 @@
import 'package:bcrypt/bcrypt.dart';
import '../database/app_database.dart';
class OnboardingRepository {
OnboardingRepository(this._db);
final AppDatabase _db;
Future<bool> isComplete() => _db.isOnboardingComplete();
Future<void> finish({
required String childName,
required String color,
required String pin,
required bool importDefaultRules,
}) async {
await _db.transaction(() async {
await _db.insertChild(prenom: childName.trim(), couleur: color);
await _db.setPinHash(BCrypt.hashpw(pin, BCrypt.gensalt()));
if (importDefaultRules) {
await _db.importDefaultPack();
}
await _db.completeOnboarding();
});
}
}
+31
View File
@@ -0,0 +1,31 @@
// Récompenses par défaut — portées depuis web/src/db.js (version famille-v2).
class DefaultRewardSeed {
const DefaultRewardSeed({
required this.libelle,
required this.coutPoints,
required this.icone,
required this.ordre,
});
final String libelle;
final int coutPoints;
final String icone;
final int ordre;
}
const rewardsVersion = 'famille-v2';
const defaultRewards = <DefaultRewardSeed>[
DefaultRewardSeed(libelle: 'Un bonbon', coutPoints: 1, icone: '🍬', ordre: 1),
DefaultRewardSeed(libelle: '10 minutes de télé', coutPoints: 1, icone: '📺', ordre: 2),
DefaultRewardSeed(libelle: 'Une sucette', coutPoints: 2, icone: '🍭', ordre: 3),
DefaultRewardSeed(libelle: 'Neige', coutPoints: 3, icone: '🍫', ordre: 4),
DefaultRewardSeed(libelle: '10 minutes de jeu vidéo', coutPoints: 3, icone: '🎮', ordre: 5),
DefaultRewardSeed(libelle: 'Cahier de dessin neuf', coutPoints: 10, icone: '📓', ordre: 6),
DefaultRewardSeed(libelle: 'Jouet pas cher (< 10 €)', coutPoints: 50, icone: '🧸', ordre: 7),
DefaultRewardSeed(libelle: 'Un biscuit / cookie', coutPoints: 1, icone: '🍪', ordre: 8),
DefaultRewardSeed(libelle: 'Choisir la musique dans la voiture', coutPoints: 1, icone: '🎵', ordre: 9),
DefaultRewardSeed(libelle: 'Choisir le menu du repas', coutPoints: 5, icone: '🍕', ordre: 10),
DefaultRewardSeed(libelle: 'Une glace', coutPoints: 5, icone: '🍦', ordre: 11),
];
+75
View File
@@ -0,0 +1,75 @@
/// Règles par défaut — portées depuis web/src/db.js (version famille-v5),
/// généralisées pour l'app publique (sans références famille prototype).
class DefaultRuleSeed {
const DefaultRuleSeed({
required this.libelle,
required this.points,
required this.icone,
required this.ordre,
required this.famille,
required this.familleOrdre,
});
final String libelle;
final int points;
final String icone;
final int ordre;
final String famille;
final int familleOrdre;
}
const rulesVersion = 'famille-v5';
class RuleFamilies {
static const maison = '🏠 Maison & rangement';
static const routine = '⏰ Matin, soir & école';
static const fratrie = '👫 Fratrie & entraide';
static const respect = '🙏 Respect & écoute';
static const ecrans = '📱 Écrans';
static const bonus = '🌟 Bonus';
}
const defaultRules = <DefaultRuleSeed>[
// Maison & rangement
DefaultRuleSeed(libelle: 'Je mets la table', points: 1, icone: '🍽️', ordre: 1, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Débarrasser la table', points: 1, icone: '🧹', ordre: 2, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Ranger le lave-vaisselle', points: 1, icone: '🫧', ordre: 3, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Nettoyer la table du séjour', points: 1, icone: '', ordre: 4, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Ranger la salle de jeu', points: 2, icone: '🎮', ordre: 5, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Ranger sa chambre', points: 1, icone: '🛏️', ordre: 6, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Ranger ses habits propres', points: 1, icone: '👕', ordre: 7, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Ranger la cabane', points: 1, icone: '🏕️', ordre: 8, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Ranger les vélos (abri jardin)', points: 1, icone: '🚲', ordre: 9, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Ranger sur ordre des parents', points: 1, icone: '👨‍👩‍👧', ordre: 10, famille: RuleFamilies.maison, familleOrdre: 1),
DefaultRuleSeed(libelle: 'Piquer dans les placards', points: -2, icone: '🗄️', ordre: 11, famille: RuleFamilies.maison, familleOrdre: 1),
// Matin, soir & école
DefaultRuleSeed(libelle: "Habits prêts + s'habiller seul (à l'heure)", points: 1, icone: '', ordre: 1, famille: RuleFamilies.routine, familleOrdre: 2),
DefaultRuleSeed(libelle: 'Se préparer pour le lit sans bagarre (< 15 min)', points: 1, icone: '🌙', ordre: 2, famille: RuleFamilies.routine, familleOrdre: 2),
DefaultRuleSeed(libelle: 'Devoirs faits sans rappel', points: 2, icone: '📚', ordre: 3, famille: RuleFamilies.routine, familleOrdre: 2),
DefaultRuleSeed(libelle: 'Activité calme manuelle 1h', points: 2, icone: '🎨', ordre: 4, famille: RuleFamilies.routine, familleOrdre: 2),
DefaultRuleSeed(libelle: 'Retard le matin', points: -1, icone: '', ordre: 5, famille: RuleFamilies.routine, familleOrdre: 2),
// Fratrie & entraide
DefaultRuleSeed(libelle: "Aider son frère/sa sœur (sans qu'on demande)", points: 2, icone: '🤝', ordre: 1, famille: RuleFamilies.fratrie, familleOrdre: 3),
DefaultRuleSeed(libelle: 'Partager un jouet sans bagarre', points: 1, icone: '🎁', ordre: 2, famille: RuleFamilies.fratrie, familleOrdre: 3),
DefaultRuleSeed(libelle: 'Dire « pardon » tout seul', points: 1, icone: '🗣️', ordre: 3, famille: RuleFamilies.fratrie, familleOrdre: 3),
DefaultRuleSeed(libelle: 'Aider le plus jeune (lire, jouer calmement)', points: 1, icone: '👶', ordre: 4, famille: RuleFamilies.fratrie, familleOrdre: 3),
DefaultRuleSeed(libelle: 'Taper, frapper', points: -2, icone: '👊', ordre: 5, famille: RuleFamilies.fratrie, familleOrdre: 3),
DefaultRuleSeed(libelle: "Ne pas s'excuser auprès de frère/sœur", points: -2, icone: '😤', ordre: 6, famille: RuleFamilies.fratrie, familleOrdre: 3),
DefaultRuleSeed(libelle: 'Provocation volontaire de frère/sœur', points: -2, icone: '😈', ordre: 7, famille: RuleFamilies.fratrie, familleOrdre: 3),
DefaultRuleSeed(libelle: "Accuser l'autre à tort", points: -2, icone: '🗣️', ordre: 8, famille: RuleFamilies.fratrie, familleOrdre: 3),
DefaultRuleSeed(libelle: 'Casser / abîmer un jouet (volontairement)', points: -3, icone: '💔', ordre: 9, famille: RuleFamilies.fratrie, familleOrdre: 3),
// Respect & écoute
DefaultRuleSeed(libelle: 'Mentir à ses parents', points: -2, icone: '🤥', ordre: 1, famille: RuleFamilies.respect, familleOrdre: 4),
DefaultRuleSeed(libelle: 'Dire des gros mots ou insultes', points: -1, icone: '🤬', ordre: 2, famille: RuleFamilies.respect, familleOrdre: 4),
DefaultRuleSeed(libelle: 'Répondre mal / faire la tête', points: -1, icone: '🙄', ordre: 3, famille: RuleFamilies.respect, familleOrdre: 4),
DefaultRuleSeed(libelle: 'Crier / hurler', points: -1, icone: '🗯️', ordre: 4, famille: RuleFamilies.respect, familleOrdre: 4),
DefaultRuleSeed(libelle: 'Faire claquer une porte', points: -1, icone: '🚪', ordre: 5, famille: RuleFamilies.respect, familleOrdre: 4),
DefaultRuleSeed(libelle: 'Tirer la langue / geste irrespectueux', points: -1, icone: '👅', ordre: 6, famille: RuleFamilies.respect, familleOrdre: 4),
DefaultRuleSeed(libelle: 'Ne pas écouter une consigne (plusieurs reprises)', points: -2, icone: '👂', ordre: 7, famille: RuleFamilies.respect, familleOrdre: 4),
DefaultRuleSeed(libelle: 'Partir sans permission (jardin, rue)', points: -3, icone: '🏃', ordre: 8, famille: RuleFamilies.respect, familleOrdre: 4),
// Écrans
DefaultRuleSeed(libelle: 'Se lever pour la télé sans autorisation (semaine)', points: -3, icone: '📺', ordre: 1, famille: RuleFamilies.ecrans, familleOrdre: 5),
DefaultRuleSeed(libelle: 'Utiliser un téléphone sans autorisation', points: -5, icone: '📱', ordre: 2, famille: RuleFamilies.ecrans, familleOrdre: 5),
// Bonus
DefaultRuleSeed(libelle: 'Bon point bonus — je suis content !', points: 1, icone: '🌟', ordre: 1, famille: RuleFamilies.bonus, familleOrdre: 6),
];
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/database_provider.dart';
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final childrenAsync = ref.watch(childrenProvider);
return DecoratedBox(
decoration: const BoxDecoration(gradient: AppTheme.primaryGradient),
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
title: const Text('Bons Points'),
actions: [
IconButton(
icon: const Icon(Icons.lock_outline),
tooltip: 'Espace parent',
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Espace parent — bientôt disponible'),
),
);
},
),
],
),
body: SafeArea(
child: childrenAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
error: (e, _) => Center(
child: Text(
'Erreur de chargement',
style: TextStyle(color: Colors.white.withValues(alpha: 0.9)),
),
),
data: (children) {
if (children.isEmpty) {
return const Center(
child: Text(
'Aucun enfant',
style: TextStyle(color: Colors.white),
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: children.length,
itemBuilder: (context, index) {
final child = children[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
leading: CircleAvatar(
radius: 28,
backgroundColor: AppTheme.parseColor(child.couleur),
child: Text(
child.prenom[0].toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
title: Text(
child.prenom,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${child.score}',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppTheme.parseColor(child.couleur),
),
),
const SizedBox(width: 4),
const Text('', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
color: Colors.grey.shade400,
),
],
),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Fiche ${child.prenom} — bientôt disponible',
),
),
);
},
),
);
},
);
},
),
),
),
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/database_provider.dart';
import '../dashboard/dashboard_screen.dart';
import 'onboarding_state.dart';
import 'steps/child_step.dart';
import 'steps/pin_step.dart';
import 'steps/rules_step.dart';
import 'steps/welcome_step.dart';
class OnboardingFlow extends ConsumerStatefulWidget {
const OnboardingFlow({super.key});
@override
ConsumerState<OnboardingFlow> createState() => _OnboardingFlowState();
}
class _OnboardingFlowState extends ConsumerState<OnboardingFlow> {
final _pageController = PageController();
bool _isSaving = false;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _goTo(int page) {
_pageController.animateToPage(
page,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
Future<void> _finish() async {
final draft = ref.read(onboardingDraftProvider);
if (draft.childName.trim().isEmpty) {
_showError('Indiquez le prénom de l\'enfant.');
_goTo(1);
return;
}
if (draft.pin.length < 4) {
_showError('Le PIN doit contenir au moins 4 chiffres.');
_goTo(2);
return;
}
if (draft.pin != draft.pinConfirm) {
_showError('Les deux PIN ne correspondent pas.');
_goTo(2);
return;
}
setState(() => _isSaving = true);
try {
final repo = ref.read(onboardingRepositoryProvider);
await repo.finish(
childName: draft.childName,
color: draft.color,
pin: draft.pin,
importDefaultRules: draft.importDefaultRules,
);
ref.invalidate(onboardingCompleteProvider);
ref.invalidate(childrenProvider);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(builder: (_) => const DashboardScreen()),
);
} catch (e) {
_showError('Erreur lors de l\'enregistrement. Réessayez.');
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red.shade700),
);
}
@override
Widget build(BuildContext context) {
return PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (_) {},
children: [
WelcomeStep(onNext: () => _goTo(1)),
ChildStep(
step: 2,
onBack: () => _goTo(0),
onNext: () {
final name = ref.read(onboardingDraftProvider).childName.trim();
if (name.isEmpty) {
_showError('Indiquez le prénom de l\'enfant.');
return;
}
_goTo(2);
},
),
PinStep(
step: 3,
onBack: () => _goTo(1),
onNext: () {
final draft = ref.read(onboardingDraftProvider);
if (draft.pin.length < 4) {
_showError('Le PIN doit contenir au moins 4 chiffres.');
return;
}
if (draft.pin != draft.pinConfirm) {
_showError('Les deux PIN ne correspondent pas.');
return;
}
_goTo(3);
},
),
RulesStep(
step: 4,
isSaving: _isSaving,
onBack: () => _goTo(2),
onFinish: _finish,
),
],
);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class OnboardingDraft {
const OnboardingDraft({
this.childName = '',
this.color = '#6C63FF',
this.pin = '',
this.pinConfirm = '',
this.importDefaultRules = true,
});
final String childName;
final String color;
final String pin;
final String pinConfirm;
final bool importDefaultRules;
OnboardingDraft copyWith({
String? childName,
String? color,
String? pin,
String? pinConfirm,
bool? importDefaultRules,
}) {
return OnboardingDraft(
childName: childName ?? this.childName,
color: color ?? this.color,
pin: pin ?? this.pin,
pinConfirm: pinConfirm ?? this.pinConfirm,
importDefaultRules: importDefaultRules ?? this.importDefaultRules,
);
}
}
class OnboardingDraftNotifier extends StateNotifier<OnboardingDraft> {
OnboardingDraftNotifier() : super(const OnboardingDraft());
void setChildName(String value) => state = state.copyWith(childName: value);
void setColor(String value) => state = state.copyWith(color: value);
void setPin(String value) => state = state.copyWith(pin: value);
void setPinConfirm(String value) => state = state.copyWith(pinConfirm: value);
void setImportDefaultRules(bool value) =>
state = state.copyWith(importDefaultRules: value);
}
final onboardingDraftProvider =
StateNotifierProvider<OnboardingDraftNotifier, OnboardingDraft>(
(ref) => OnboardingDraftNotifier(),
);
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_theme.dart';
import '../onboarding_state.dart';
import '../widgets/onboarding_scaffold.dart';
class ChildStep extends ConsumerStatefulWidget {
const ChildStep({
super.key,
required this.step,
required this.onBack,
required this.onNext,
});
final int step;
final VoidCallback onBack;
final VoidCallback onNext;
@override
ConsumerState<ChildStep> createState() => _ChildStepState();
}
class _ChildStepState extends ConsumerState<ChildStep> {
late final TextEditingController _nameController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(
text: ref.read(onboardingDraftProvider).childName,
);
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final draft = ref.watch(onboardingDraftProvider);
final notifier = ref.read(onboardingDraftProvider.notifier);
return OnboardingScaffold(
step: widget.step,
title: 'Premier enfant',
subtitle: 'Créez le profil de votre enfant',
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
autofocus: true,
controller: _nameController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
hintText: 'Ex. Léa',
),
onChanged: notifier.setChildName,
),
const SizedBox(height: 24),
Text(
'Couleur',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: AppTheme.childColors.map((hex) {
final selected = draft.color == hex;
return GestureDetector(
onTap: () => notifier.setColor(hex),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppTheme.parseColor(hex),
shape: BoxShape.circle,
border: Border.all(
color: selected ? Colors.black87 : Colors.transparent,
width: 3,
),
boxShadow: selected
? [
BoxShadow(
color: AppTheme.parseColor(hex)
.withValues(alpha: 0.5),
blurRadius: 8,
),
]
: null,
),
child: selected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
const Spacer(),
Center(
child: CircleAvatar(
radius: 40,
backgroundColor: AppTheme.parseColor(draft.color),
child: Text(
draft.childName.isNotEmpty
? draft.childName[0].toUpperCase()
: '?',
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
bottom: Row(
children: [
TextButton(onPressed: widget.onBack, child: const Text('Retour')),
const Spacer(),
FilledButton(onPressed: widget.onNext, child: const Text('Suivant')),
],
),
);
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../onboarding_state.dart';
import '../widgets/onboarding_scaffold.dart';
class PinStep extends ConsumerStatefulWidget {
const PinStep({
super.key,
required this.step,
required this.onBack,
required this.onNext,
});
final int step;
final VoidCallback onBack;
final VoidCallback onNext;
@override
ConsumerState<PinStep> createState() => _PinStepState();
}
class _PinStepState extends ConsumerState<PinStep> {
final _pinController = TextEditingController();
final _confirmController = TextEditingController();
bool _obscurePin = true;
bool _obscureConfirm = true;
@override
void dispose() {
_pinController.dispose();
_confirmController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final notifier = ref.read(onboardingDraftProvider.notifier);
return OnboardingScaffold(
step: widget.step,
title: 'PIN parent',
subtitle: 'Protège l\'espace parent (4 chiffres minimum)',
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _pinController,
obscureText: _obscurePin,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(8),
],
decoration: InputDecoration(
labelText: 'Choisir un PIN',
suffixIcon: IconButton(
icon: Icon(
_obscurePin ? Icons.visibility : Icons.visibility_off,
),
onPressed: () =>
setState(() => _obscurePin = !_obscurePin),
),
),
onChanged: notifier.setPin,
),
const SizedBox(height: 16),
TextField(
controller: _confirmController,
obscureText: _obscureConfirm,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(8),
],
decoration: InputDecoration(
labelText: 'Confirmer le PIN',
suffixIcon: IconButton(
icon: Icon(
_obscureConfirm
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () =>
setState(() => _obscureConfirm = !_obscureConfirm),
),
),
onChanged: notifier.setPinConfirm,
),
const SizedBox(height: 20),
Text(
'Ce PIN sera demandé pour accéder à l\'espace parent '
'(appliquer des règles, modifier les réglages).',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
),
),
bottom: Row(
children: [
TextButton(onPressed: widget.onBack, child: const Text('Retour')),
const Spacer(),
FilledButton(onPressed: widget.onNext, child: const Text('Suivant')),
],
),
);
}
}
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/seed/default_rewards.dart';
import '../../../data/seed/default_rules.dart';
import '../onboarding_state.dart';
import '../widgets/onboarding_scaffold.dart';
class RulesStep extends ConsumerWidget {
const RulesStep({
super.key,
required this.step,
required this.isSaving,
required this.onBack,
required this.onFinish,
});
final int step;
final bool isSaving;
final VoidCallback onBack;
final VoidCallback onFinish;
@override
Widget build(BuildContext context, WidgetRef ref) {
final draft = ref.watch(onboardingDraftProvider);
final notifier = ref.read(onboardingDraftProvider.notifier);
return OnboardingScaffold(
step: step,
title: 'Règles par défaut',
subtitle: 'Pack « Maison & école » prêt à l\'emploi',
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Importer le pack par défaut'),
subtitle: Text(
'${defaultRules.length} règles et '
'${defaultRewards.length} récompenses',
),
value: draft.importDefaultRules,
onChanged: isSaving ? null : notifier.setImportDefaultRules,
),
const Divider(),
Expanded(
child: draft.importDefaultRules
? ListView(
children: [
Text(
'Familles de règles incluses :',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
..._ruleFamilies.map(
(f) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('$f'),
),
),
const SizedBox(height: 16),
Text(
'Vous pourrez modifier les règles plus tard '
'depuis l\'espace parent (Premium pour '
'l\'édition complète).',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: Colors.grey.shade600),
),
],
)
: Center(
child: Text(
'Vous partirez sans règles ni récompenses. '
'Vous pourrez les ajouter manuellement plus tard.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
],
),
),
),
bottom: Row(
children: [
TextButton(
onPressed: isSaving ? null : onBack,
child: const Text('Retour'),
),
const Spacer(),
FilledButton(
onPressed: isSaving ? null : onFinish,
child: isSaving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('C\'est parti !'),
),
],
),
);
}
}
const _ruleFamilies = [
RuleFamilies.maison,
RuleFamilies.routine,
RuleFamilies.fratrie,
RuleFamilies.respect,
RuleFamilies.ecrans,
RuleFamilies.bonus,
];
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../widgets/onboarding_scaffold.dart';
class WelcomeStep extends StatelessWidget {
const WelcomeStep({super.key, required this.onNext});
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return OnboardingScaffold(
step: 1,
title: 'Bons Points',
subtitle: 'Simple, privé, 100 % sur votre téléphone',
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('', style: TextStyle(fontSize: 56)),
const SizedBox(height: 20),
_FeatureRow(
icon: Icons.phone_android,
text: 'Aucun compte, aucun cloud',
),
const SizedBox(height: 12),
_FeatureRow(
icon: Icons.wifi_off,
text: 'Fonctionne hors ligne',
),
const SizedBox(height: 12),
_FeatureRow(
icon: Icons.family_restroom,
text: 'Bons points pour toute la famille',
),
const SizedBox(height: 12),
_FeatureRow(
icon: Icons.lock_outline,
text: 'Espace parent protégé par PIN',
),
],
),
),
),
bottom: FilledButton(
onPressed: onNext,
child: const Text('Commencer'),
),
);
}
}
class _FeatureRow extends StatelessWidget {
const _FeatureRow({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: const Color(0xFF667EEA)),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
);
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
class OnboardingScaffold extends StatelessWidget {
const OnboardingScaffold({
super.key,
required this.title,
required this.subtitle,
required this.child,
this.bottom,
this.step,
this.totalSteps = 4,
});
final String title;
final String subtitle;
final Widget child;
final Widget? bottom;
final int? step;
final int totalSteps;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(gradient: AppTheme.primaryGradient),
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (step != null) ...[
const SizedBox(height: 8),
Row(
children: List.generate(totalSteps, (index) {
final active = index < step!;
return Expanded(
child: Container(
height: 4,
margin: EdgeInsets.only(
right: index < totalSteps - 1 ? 6 : 0,
),
decoration: BoxDecoration(
color: active
? Colors.white
: Colors.white.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(2),
),
),
);
}),
),
],
const SizedBox(height: 24),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
subtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.white.withValues(alpha: 0.9),
),
),
const SizedBox(height: 24),
Expanded(child: child),
if (bottom != null) ...[
const SizedBox(height: 16),
bottom!,
const SizedBox(height: 16),
],
],
),
),
),
),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(
const ProviderScope(
child: BonpointApp(),
),
);
}
@@ -0,0 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/database/app_database.dart';
import '../data/repositories/onboarding_repository.dart';
final databaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase();
ref.onDispose(db.close);
return db;
});
final onboardingRepositoryProvider = Provider<OnboardingRepository>((ref) {
return OnboardingRepository(ref.watch(databaseProvider));
});
final onboardingCompleteProvider = FutureProvider<bool>((ref) async {
final repo = ref.watch(onboardingRepositoryProvider);
return repo.isComplete();
});
final childrenProvider = FutureProvider<List<Enfant>>((ref) async {
final db = ref.watch(databaseProvider);
return db.listChildren();
});