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
+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),
];