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