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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Julien Martin
2026-06-12 12:12:01 +02:00
co-authored by Cursor
parent a82dffe959
commit aac3a729a2
45 changed files with 6328 additions and 4 deletions
@@ -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,
),
),
],
);
}
}