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:
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user