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
parent a82dffe959
commit aac3a729a2
45 changed files with 6328 additions and 4 deletions

45
mobile/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

30
mobile/.metadata Normal file
View File

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
- platform: android
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@ -1,7 +1,54 @@
# Application mobile Android # Bons Points — Application Android (Flutter)
Emplacement prévu pour l'app **Flutter** (Bons Points — produit commercial Play Store). App familiale de bons points, **100 % locale** (SQLite, pas de compte cloud).
Voir le cahier des charges : [../docs/CAHIER_DES_CHARGES_APP_ANDROID.md](../docs/CAHIER_DES_CHARGES_APP_ANDROID.md). ## Stack (choix par défaut — voir CDC §14)
**Statut :** non initialisé — à développer dans un workspace Cursor dédié. | Couche | Choix |
|--------|--------|
| UI | Flutter + Material 3 |
| État | **Riverpod** |
| BDD | **Drift** (SQLite) |
| PIN | bcrypt |
## Démarrage
```bash
cd mobile
flutter pub get
dart run build_runner build # après modification des tables Drift
flutter run
```
## Structure
```
lib/
├── main.dart / app.dart
├── core/theme/ # charte visuelle (prototype web)
├── data/
│ ├── database/ # schéma SQLite (port de web/src/db.js)
│ ├── seed/ # règles & récompenses par défaut
│ └── repositories/
├── providers/
└── features/
├── onboarding/ # bienvenue → enfant → PIN → règles
└── dashboard/ # tableau de bord MVP
```
## État actuel (phase 1)
- [x] Projet Flutter (`fr.ptitspas.bonpoint`, Android seul)
- [x] Schéma SQLite + seed règles/récompenses
- [x] Logique score (plancher 0, delta réel)
- [x] Onboarding sans compte (4 étapes)
- [x] Tableau de bord minimal
- [ ] Espace parent (PIN, grille règles)
- [ ] Fiche enfant + boutique
- [ ] Export/import, IAP, AdMob
## Tests
```bash
flutter test
```

View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
mobile/android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "fr.ptitspas.bonpoint.bonpoint"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "fr.ptitspas.bonpoint.bonpoint"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="bonpoint"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package fr.ptitspas.bonpoint.bonpoint
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")

71
mobile/lib/app.dart Normal file
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),
],
),
),
),
);
}
}

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),
),
);
}
}

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

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};
}

View File

@ -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();
});
}
}

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

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

View File

@ -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',
),
),
);
},
),
);
},
);
},
),
),
),
);
}
}

View File

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

View File

@ -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(),
);

View File

@ -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')),
],
),
);
}
}

View File

@ -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')),
],
),
);
}
}

View File

@ -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,
];

View File

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

View File

@ -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
mobile/lib/main.dart Normal file
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(),
),
);
}

View File

@ -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();
});

741
mobile/pubspec.lock Normal file
View File

@ -0,0 +1,741 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev"
source: hosted
version: "99.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev"
source: hosted
version: "12.1.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
bcrypt:
dependency: "direct main"
description:
name: bcrypt
sha256: "6073a700cbbc59f1d4ab27cd532755e3de5e676c4941f535f351374df849270b"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "4.0.6"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
url: "https://pub.dev"
source: hosted
version: "2.15.0"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.dev"
source: hosted
version: "8.12.6"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
url: "https://pub.dev"
source: hosted
version: "3.1.8"
drift:
dependency: "direct main"
description:
name: drift
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
url: "https://pub.dev"
source: hosted
version: "2.34.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
url: "https://pub.dev"
source: hosted
version: "2.34.0"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
url: "https://pub.dev"
source: hosted
version: "0.19.1"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "4.2.3"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.5.42"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "40bdddb306a727be9ce510bd2d2b9a6c9db6c586d846ef7b22e3990a2b24f02d"
url: "https://pub.dev"
source: hosted
version: "0.44.5"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.38.4"

30
mobile/pubspec.yaml Normal file
View File

@ -0,0 +1,30 @@
name: bonpoint
description: "Bons Points — système de bons points familial, 100 % local."
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.12.2
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
flutter_riverpod: ^2.6.1
drift: ^2.26.0
sqlite3_flutter_libs: ^0.5.31
path_provider: ^2.1.5
path: ^1.9.1
bcrypt: ^1.1.3
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
drift_dev: ^2.26.0
build_runner: ^2.4.15
flutter:
uses-material-design: true

View File

@ -0,0 +1,60 @@
import 'package:bonpoint/data/database/app_database.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late AppDatabase db;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await db.importDefaultPack();
});
tearDown(() async {
await db.close();
});
test('appliquerRegle respecte le plancher à 0', () async {
final childId = await db.insertChild(
prenom: 'Test',
couleur: '#6C63FF',
);
final rules = await db.select(db.regles).get();
final positiveRule = rules.firstWhere((r) => r.points == 1);
final negativeRule = rules.firstWhere((r) => r.points == -2);
await db.applyRule(childId, positiveRule.id);
// Score = 1, puis règle -2 score 0, delta réel -1
await db.applyRule(childId, negativeRule.id);
// Score déjà 0, règle -2 score 0, delta réel 0
await db.applyRule(childId, negativeRule.id);
final child = await (db.select(db.enfants)
..where((e) => e.id.equals(childId)))
.getSingle();
expect(child.score, 0);
final movements = await db.select(db.mouvements).get();
expect(movements.length, 3);
expect(movements[0].delta, 1);
expect(movements[1].delta, -1);
expect(movements[2].delta, 0);
});
test('delta enregistré est le delta réel (pas le delta théorique)', () async {
final childId = await db.insertChild(
prenom: 'Test',
couleur: '#6C63FF',
);
final rules = await db.select(db.regles).get();
final rule = rules.firstWhere((r) => r.points == -2);
await db.applyRule(childId, rule.id);
final movement = await db.select(db.mouvements).getSingle();
expect(movement.delta, 0);
});
}

View File

@ -0,0 +1,23 @@
import 'package:bonpoint/app.dart';
import 'package:bonpoint/providers/database_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Affiche l\'écran de bienvenue au premier lancement',
(tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
onboardingCompleteProvider.overrideWith((ref) async => false),
],
child: const BonpointApp(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Bons Points'), findsWidgets);
expect(find.text('Commencer'), findsOneWidget);
});
}