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,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../providers/database_provider.dart';
|
||||
import '../dashboard/dashboard_screen.dart';
|
||||
import 'onboarding_state.dart';
|
||||
import 'steps/child_step.dart';
|
||||
import 'steps/pin_step.dart';
|
||||
import 'steps/rules_step.dart';
|
||||
import 'steps/welcome_step.dart';
|
||||
|
||||
class OnboardingFlow extends ConsumerStatefulWidget {
|
||||
const OnboardingFlow({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OnboardingFlow> createState() => _OnboardingFlowState();
|
||||
}
|
||||
|
||||
class _OnboardingFlowState extends ConsumerState<OnboardingFlow> {
|
||||
final _pageController = PageController();
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _goTo(int page) {
|
||||
_pageController.animateToPage(
|
||||
page,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _finish() async {
|
||||
final draft = ref.read(onboardingDraftProvider);
|
||||
if (draft.childName.trim().isEmpty) {
|
||||
_showError('Indiquez le prénom de l\'enfant.');
|
||||
_goTo(1);
|
||||
return;
|
||||
}
|
||||
if (draft.pin.length < 4) {
|
||||
_showError('Le PIN doit contenir au moins 4 chiffres.');
|
||||
_goTo(2);
|
||||
return;
|
||||
}
|
||||
if (draft.pin != draft.pinConfirm) {
|
||||
_showError('Les deux PIN ne correspondent pas.');
|
||||
_goTo(2);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final repo = ref.read(onboardingRepositoryProvider);
|
||||
await repo.finish(
|
||||
childName: draft.childName,
|
||||
color: draft.color,
|
||||
pin: draft.pin,
|
||||
importDefaultRules: draft.importDefaultRules,
|
||||
);
|
||||
ref.invalidate(onboardingCompleteProvider);
|
||||
ref.invalidate(childrenProvider);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(builder: (_) => const DashboardScreen()),
|
||||
);
|
||||
} catch (e) {
|
||||
_showError('Erreur lors de l\'enregistrement. Réessayez.');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red.shade700),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageView(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onPageChanged: (_) {},
|
||||
children: [
|
||||
WelcomeStep(onNext: () => _goTo(1)),
|
||||
ChildStep(
|
||||
step: 2,
|
||||
onBack: () => _goTo(0),
|
||||
onNext: () {
|
||||
final name = ref.read(onboardingDraftProvider).childName.trim();
|
||||
if (name.isEmpty) {
|
||||
_showError('Indiquez le prénom de l\'enfant.');
|
||||
return;
|
||||
}
|
||||
_goTo(2);
|
||||
},
|
||||
),
|
||||
PinStep(
|
||||
step: 3,
|
||||
onBack: () => _goTo(1),
|
||||
onNext: () {
|
||||
final draft = ref.read(onboardingDraftProvider);
|
||||
if (draft.pin.length < 4) {
|
||||
_showError('Le PIN doit contenir au moins 4 chiffres.');
|
||||
return;
|
||||
}
|
||||
if (draft.pin != draft.pinConfirm) {
|
||||
_showError('Les deux PIN ne correspondent pas.');
|
||||
return;
|
||||
}
|
||||
_goTo(3);
|
||||
},
|
||||
),
|
||||
RulesStep(
|
||||
step: 4,
|
||||
isSaving: _isSaving,
|
||||
onBack: () => _goTo(2),
|
||||
onFinish: _finish,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class OnboardingDraft {
|
||||
const OnboardingDraft({
|
||||
this.childName = '',
|
||||
this.color = '#6C63FF',
|
||||
this.pin = '',
|
||||
this.pinConfirm = '',
|
||||
this.importDefaultRules = true,
|
||||
});
|
||||
|
||||
final String childName;
|
||||
final String color;
|
||||
final String pin;
|
||||
final String pinConfirm;
|
||||
final bool importDefaultRules;
|
||||
|
||||
OnboardingDraft copyWith({
|
||||
String? childName,
|
||||
String? color,
|
||||
String? pin,
|
||||
String? pinConfirm,
|
||||
bool? importDefaultRules,
|
||||
}) {
|
||||
return OnboardingDraft(
|
||||
childName: childName ?? this.childName,
|
||||
color: color ?? this.color,
|
||||
pin: pin ?? this.pin,
|
||||
pinConfirm: pinConfirm ?? this.pinConfirm,
|
||||
importDefaultRules: importDefaultRules ?? this.importDefaultRules,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OnboardingDraftNotifier extends StateNotifier<OnboardingDraft> {
|
||||
OnboardingDraftNotifier() : super(const OnboardingDraft());
|
||||
|
||||
void setChildName(String value) => state = state.copyWith(childName: value);
|
||||
void setColor(String value) => state = state.copyWith(color: value);
|
||||
void setPin(String value) => state = state.copyWith(pin: value);
|
||||
void setPinConfirm(String value) => state = state.copyWith(pinConfirm: value);
|
||||
void setImportDefaultRules(bool value) =>
|
||||
state = state.copyWith(importDefaultRules: value);
|
||||
}
|
||||
|
||||
final onboardingDraftProvider =
|
||||
StateNotifierProvider<OnboardingDraftNotifier, OnboardingDraft>(
|
||||
(ref) => OnboardingDraftNotifier(),
|
||||
);
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../onboarding_state.dart';
|
||||
import '../widgets/onboarding_scaffold.dart';
|
||||
|
||||
class ChildStep extends ConsumerStatefulWidget {
|
||||
const ChildStep({
|
||||
super.key,
|
||||
required this.step,
|
||||
required this.onBack,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final int step;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
ConsumerState<ChildStep> createState() => _ChildStepState();
|
||||
}
|
||||
|
||||
class _ChildStepState extends ConsumerState<ChildStep> {
|
||||
late final TextEditingController _nameController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(
|
||||
text: ref.read(onboardingDraftProvider).childName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final draft = ref.watch(onboardingDraftProvider);
|
||||
final notifier = ref.read(onboardingDraftProvider.notifier);
|
||||
|
||||
return OnboardingScaffold(
|
||||
step: widget.step,
|
||||
title: 'Premier enfant',
|
||||
subtitle: 'Créez le profil de votre enfant',
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
autofocus: true,
|
||||
controller: _nameController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prénom',
|
||||
hintText: 'Ex. Léa',
|
||||
),
|
||||
onChanged: notifier.setChildName,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Couleur',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: AppTheme.childColors.map((hex) {
|
||||
final selected = draft.color == hex;
|
||||
return GestureDetector(
|
||||
onTap: () => notifier.setColor(hex),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.parseColor(hex),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected ? Colors.black87 : Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: selected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppTheme.parseColor(hex)
|
||||
.withValues(alpha: 0.5),
|
||||
blurRadius: 8,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: selected
|
||||
? const Icon(Icons.check, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const Spacer(),
|
||||
Center(
|
||||
child: CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: AppTheme.parseColor(draft.color),
|
||||
child: Text(
|
||||
draft.childName.isNotEmpty
|
||||
? draft.childName[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottom: Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onBack, child: const Text('Retour')),
|
||||
const Spacer(),
|
||||
FilledButton(onPressed: widget.onNext, child: const Text('Suivant')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../onboarding_state.dart';
|
||||
import '../widgets/onboarding_scaffold.dart';
|
||||
|
||||
class PinStep extends ConsumerStatefulWidget {
|
||||
const PinStep({
|
||||
super.key,
|
||||
required this.step,
|
||||
required this.onBack,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final int step;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
ConsumerState<PinStep> createState() => _PinStepState();
|
||||
}
|
||||
|
||||
class _PinStepState extends ConsumerState<PinStep> {
|
||||
final _pinController = TextEditingController();
|
||||
final _confirmController = TextEditingController();
|
||||
bool _obscurePin = true;
|
||||
bool _obscureConfirm = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pinController.dispose();
|
||||
_confirmController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notifier = ref.read(onboardingDraftProvider.notifier);
|
||||
|
||||
return OnboardingScaffold(
|
||||
step: widget.step,
|
||||
title: 'PIN parent',
|
||||
subtitle: 'Protège l\'espace parent (4 chiffres minimum)',
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _pinController,
|
||||
obscureText: _obscurePin,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(8),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Choisir un PIN',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePin ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePin = !_obscurePin),
|
||||
),
|
||||
),
|
||||
onChanged: notifier.setPin,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _confirmController,
|
||||
obscureText: _obscureConfirm,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(8),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Confirmer le PIN',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirm
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscureConfirm = !_obscureConfirm),
|
||||
),
|
||||
),
|
||||
onChanged: notifier.setPinConfirm,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Ce PIN sera demandé pour accéder à l\'espace parent '
|
||||
'(appliquer des règles, modifier les réglages).',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottom: Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onBack, child: const Text('Retour')),
|
||||
const Spacer(),
|
||||
FilledButton(onPressed: widget.onNext, child: const Text('Suivant')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../data/seed/default_rewards.dart';
|
||||
import '../../../data/seed/default_rules.dart';
|
||||
import '../onboarding_state.dart';
|
||||
import '../widgets/onboarding_scaffold.dart';
|
||||
|
||||
class RulesStep extends ConsumerWidget {
|
||||
const RulesStep({
|
||||
super.key,
|
||||
required this.step,
|
||||
required this.isSaving,
|
||||
required this.onBack,
|
||||
required this.onFinish,
|
||||
});
|
||||
|
||||
final int step;
|
||||
final bool isSaving;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onFinish;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final draft = ref.watch(onboardingDraftProvider);
|
||||
final notifier = ref.read(onboardingDraftProvider.notifier);
|
||||
|
||||
return OnboardingScaffold(
|
||||
step: step,
|
||||
title: 'Règles par défaut',
|
||||
subtitle: 'Pack « Maison & école » prêt à l\'emploi',
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Importer le pack par défaut'),
|
||||
subtitle: Text(
|
||||
'${defaultRules.length} règles et '
|
||||
'${defaultRewards.length} récompenses',
|
||||
),
|
||||
value: draft.importDefaultRules,
|
||||
onChanged: isSaving ? null : notifier.setImportDefaultRules,
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: draft.importDefaultRules
|
||||
? ListView(
|
||||
children: [
|
||||
Text(
|
||||
'Familles de règles incluses :',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._ruleFamilies.map(
|
||||
(f) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text('• $f'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous pourrez modifier les règles plus tard '
|
||||
'depuis l\'espace parent (Premium pour '
|
||||
'l\'édition complète).',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
'Vous partirez sans règles ni récompenses. '
|
||||
'Vous pourrez les ajouter manuellement plus tard.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottom: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isSaving ? null : onBack,
|
||||
child: const Text('Retour'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: isSaving ? null : onFinish,
|
||||
child: isSaving
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('C\'est parti !'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _ruleFamilies = [
|
||||
RuleFamilies.maison,
|
||||
RuleFamilies.routine,
|
||||
RuleFamilies.fratrie,
|
||||
RuleFamilies.respect,
|
||||
RuleFamilies.ecrans,
|
||||
RuleFamilies.bonus,
|
||||
];
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/onboarding_scaffold.dart';
|
||||
|
||||
class WelcomeStep extends StatelessWidget {
|
||||
const WelcomeStep({super.key, required this.onNext});
|
||||
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OnboardingScaffold(
|
||||
step: 1,
|
||||
title: 'Bons Points',
|
||||
subtitle: 'Simple, privé, 100 % sur votre téléphone',
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('⭐', style: TextStyle(fontSize: 56)),
|
||||
const SizedBox(height: 20),
|
||||
_FeatureRow(
|
||||
icon: Icons.phone_android,
|
||||
text: 'Aucun compte, aucun cloud',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FeatureRow(
|
||||
icon: Icons.wifi_off,
|
||||
text: 'Fonctionne hors ligne',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FeatureRow(
|
||||
icon: Icons.family_restroom,
|
||||
text: 'Bons points pour toute la famille',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FeatureRow(
|
||||
icon: Icons.lock_outline,
|
||||
text: 'Espace parent protégé par PIN',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottom: FilledButton(
|
||||
onPressed: onNext,
|
||||
child: const Text('Commencer'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureRow extends StatelessWidget {
|
||||
const _FeatureRow({required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: const Color(0xFF667EEA)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
|
||||
class OnboardingScaffold extends StatelessWidget {
|
||||
const OnboardingScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
this.bottom,
|
||||
this.step,
|
||||
this.totalSteps = 4,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
final Widget? bottom;
|
||||
final int? step;
|
||||
final int totalSteps;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(gradient: AppTheme.primaryGradient),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (step != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: List.generate(totalSteps, (index) {
|
||||
final active = index < step!;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 4,
|
||||
margin: EdgeInsets.only(
|
||||
right: index < totalSteps - 1 ? 6 : 0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? Colors.white
|
||||
: Colors.white.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(child: child),
|
||||
if (bottom != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
bottom!,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user