feat(#78): Finaliser layout mobile responsive de PersonalInfoFormScreen

Layout mobile complètement repensé avec séparation desktop/mobile :

**Layout Desktop (_buildDesktopFields) :**
- Champs par paires horizontales (Row avec Expanded)
- Code Postal + Ville avec ratio flex 2:5
- Espacement 32px entre lignes
- Taille police : 22px labels, 20px input

**Layout Mobile (_buildMobileFields) :**
- Tous les champs empilés verticalement (Column pure)
- Chaque champ prend toute la largeur
- Espacement 12px entre champs (compact)
- Taille police : 15px labels, 14px input
- Hauteur champs réduite : 45px

**Nouveau widget CustomNavigationButton :**
- Widget réutilisable pour boutons navigation
- Enum NavigationButtonStyle (green/purple)
- Utilise assets images comme fond
- Bouton "Précédent" : fond lavande, texte violet foncé
- Bouton "Suivant" : fond vert, texte vert foncé

**Boutons mobile :**
- Positionnés sous la carte (dans le scroll)
- Aligned avec les marges de la carte (5% de chaque côté)
- Prennent toute la largeur avec Expanded
- Écart de 16px entre les deux
- Utilisation de CustomNavigationButton

**Optimisations mobile :**
- Padding carte réduit : 20px vertical (vs 40px initial)
- Toggles compacts (Switch scale 0.85)
- Titre : 18px (vs 24px desktop)
- Étape : 13px (vs 16px desktop)

Référence: #78

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-04 11:01:43 +01:00
parent 1d774f29eb
commit a57993a90f
2 changed files with 335 additions and 115 deletions

View File

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Style de bouton de navigation
enum NavigationButtonStyle {
green, // Bouton vert avec texte vert foncé
purple, // Bouton violet avec texte violet foncé
}
/// Widget de bouton de navigation personnalisé
/// Utilise les assets existants pour le fond
class CustomNavigationButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;
final NavigationButtonStyle style;
final double? width;
final double height;
final double fontSize;
const CustomNavigationButton({
super.key,
required this.text,
required this.onPressed,
this.style = NavigationButtonStyle.green,
this.width,
this.height = 50,
this.fontSize = 16,
});
@override
Widget build(BuildContext context) {
final backgroundImage = _getBackgroundImage();
final textColor = _getTextColor();
return SizedBox(
width: width,
height: height,
child: Stack(
children: [
// Fond avec image
Positioned.fill(
child: Image.asset(
backgroundImage,
fit: BoxFit.fill,
),
),
// Bouton cliquable
Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Center(
child: Text(
text,
style: GoogleFonts.merienda(
color: textColor,
fontSize: fontSize,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
);
}
String _getBackgroundImage() {
switch (style) {
case NavigationButtonStyle.green:
return 'assets/images/bg_green.png';
case NavigationButtonStyle.purple:
return 'assets/images/bg_lavender.png';
}
}
Color _getTextColor() {
switch (style) {
case NavigationButtonStyle.green:
return const Color(0xFF2E7D32); // Vert foncé
case NavigationButtonStyle.purple:
return const Color(0xFF5E35B1); // Violet foncé
}
}
}

View File

@ -5,6 +5,8 @@ import 'dart:math' as math;
import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart';
import 'hover_relief_widget.dart';
import 'custom_navigation_button.dart';
import '../models/card_assets.dart';
import '../config/display_config.dart';
@ -160,25 +162,30 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(widget.stepText, style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54)),
const SizedBox(height: 10),
Text(
widget.stepText,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 13 : 16,
color: Colors.black54,
),
),
SizedBox(height: config.isMobile ? 6 : 10),
Text(
widget.title,
style: GoogleFonts.merienda(
fontSize: 24,
fontSize: config.isMobile ? 18 : 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
SizedBox(height: config.isMobile ? 16 : 30),
Container(
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
padding: EdgeInsets.symmetric(
vertical: config.isMobile ? 30 : 50,
horizontal: config.isMobile ? 20 : 50,
vertical: config.isMobile ? 20 : 50,
horizontal: config.isMobile ? 24 : 50,
),
constraints: BoxConstraints(minHeight: config.isMobile ? 400 : 570),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(
@ -204,11 +211,57 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
),
),
),
// Boutons mobile sous la carte (dans le scroll)
if (config.isMobile) ...[
const SizedBox(height: 20),
Padding(
padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté)
),
child: Row(
children: [
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Précédent',
style: NavigationButtonStyle.purple,
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
const SizedBox(width: 16), // Écart entre les boutons
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Suivant',
style: NavigationButtonStyle.green,
onPressed: _handleSubmit,
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
],
),
),
const SizedBox(height: 10),
],
],
),
),
),
// Chevrons de navigation
// Chevrons de navigation (desktop uniquement)
if (!config.isMobile) ...[
Positioned(
top: screenSize.height / 2 - 20,
@ -239,32 +292,6 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
),
),
],
// Boutons mobile en bas
if (config.isMobile)
Positioned(
bottom: 20,
left: 20,
right: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
child: const Text('Précédent'),
),
ElevatedButton(
onPressed: _handleSubmit,
child: const Text('Suivant'),
),
],
),
),
],
),
);
@ -273,15 +300,15 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
/// Construit les toggles (Parent 2 / Même adresse)
Widget _buildToggles(BuildContext context, DisplayConfig config) {
if (config.isMobile) {
// Layout vertical sur mobile
// Layout vertical sur mobile - toggles compacts
return Column(
children: [
_buildSecondPersonToggle(context),
_buildSecondPersonToggle(context, config),
if (widget.showSameAddressCheckbox) ...[
const SizedBox(height: 16),
_buildSameAddressToggle(context),
const SizedBox(height: 12),
_buildSameAddressToggle(context, config),
],
const SizedBox(height: 32),
const SizedBox(height: 24),
],
);
} else {
@ -292,13 +319,13 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
children: [
Expanded(
flex: 12,
child: _buildSecondPersonToggle(context),
child: _buildSecondPersonToggle(context, config),
),
const Expanded(flex: 1, child: SizedBox()),
if (widget.showSameAddressCheckbox)
Expanded(
flex: 12,
child: _buildSameAddressToggle(context),
child: _buildSameAddressToggle(context, config),
),
],
),
@ -308,61 +335,69 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
}
}
Widget _buildSecondPersonToggle(BuildContext context) {
Widget _buildSecondPersonToggle(BuildContext context, DisplayConfig config) {
return Row(
children: [
const Icon(Icons.person_add_alt_1, size: 20),
const SizedBox(width: 8),
Flexible(
Icon(Icons.person_add_alt_1, size: config.isMobile ? 18 : 20),
SizedBox(width: config.isMobile ? 6 : 8),
Expanded(
child: Text(
'Ajouter Parent 2 ?',
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
style: GoogleFonts.merienda(
fontWeight: FontWeight.bold,
fontSize: config.isMobile ? 14 : 16,
),
overflow: TextOverflow.ellipsis,
),
),
const Spacer(),
Switch(
value: _hasSecondPerson,
onChanged: (value) {
setState(() {
_hasSecondPerson = value;
_fieldsEnabled = value;
});
},
activeColor: Theme.of(context).primaryColor,
Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _hasSecondPerson,
onChanged: (value) {
setState(() {
_hasSecondPerson = value;
_fieldsEnabled = value;
});
},
activeColor: Theme.of(context).primaryColor,
),
),
],
);
}
Widget _buildSameAddressToggle(BuildContext context) {
Widget _buildSameAddressToggle(BuildContext context, DisplayConfig config) {
return Row(
children: [
Icon(
Icons.home_work_outlined,
size: 20,
size: config.isMobile ? 18 : 20,
color: _fieldsEnabled ? null : Colors.grey,
),
const SizedBox(width: 8),
Flexible(
SizedBox(width: config.isMobile ? 6 : 8),
Expanded(
child: Text(
'Même Adresse ?',
style: GoogleFonts.merienda(
color: _fieldsEnabled ? null : Colors.grey,
fontSize: config.isMobile ? 14 : 16,
),
overflow: TextOverflow.ellipsis,
),
),
const Spacer(),
Switch(
value: _sameAddress,
onChanged: _fieldsEnabled ? (value) {
setState(() {
_sameAddress = value ?? false;
_updateAddressFields();
});
} : null,
activeColor: Theme.of(context).primaryColor,
Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _sameAddress,
onChanged: _fieldsEnabled ? (value) {
setState(() {
_sameAddress = value ?? false;
_updateAddressFields();
});
} : null,
activeColor: Theme.of(context).primaryColor,
),
),
],
);
@ -370,53 +405,70 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
/// Construit les champs du formulaire avec la nouvelle infrastructure
Widget _buildFormFields(BuildContext context, DisplayConfig config) {
if (config.isMobile) {
return _buildMobileFields(context, config);
} else {
return _buildDesktopFields(context, config);
}
}
/// Layout DESKTOP : champs côte à côte (horizontal)
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
return Column(
children: [
// Nom et Prénom
FormFieldRow(
config: config,
fields: [
_buildField(
config: config,
label: 'Nom',
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
Row(
children: [
Expanded(
child: _buildField(
config: config,
label: 'Nom',
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
),
),
_buildField(
config: config,
label: 'Prénom',
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
const SizedBox(width: 20),
Expanded(
child: _buildField(
config: config,
label: 'Prénom',
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
),
),
],
),
SizedBox(height: config.isMobile ? 16 : 32),
const SizedBox(height: 32),
// Téléphone et Email
FormFieldRow(
config: config,
fields: [
_buildField(
config: config,
label: 'Téléphone',
controller: _phoneController,
hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone,
enabled: _fieldsEnabled,
Row(
children: [
Expanded(
child: _buildField(
config: config,
label: 'Téléphone',
controller: _phoneController,
hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone,
enabled: _fieldsEnabled,
),
),
_buildField(
config: config,
label: 'Email',
controller: _emailController,
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
const SizedBox(width: 20),
Expanded(
child: _buildField(
config: config,
label: 'Email',
controller: _emailController,
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
),
),
],
),
SizedBox(height: config.isMobile ? 16 : 32),
const SizedBox(height: 32),
// Adresse
_buildField(
@ -426,14 +478,13 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress,
),
SizedBox(height: config.isMobile ? 16 : 32),
const SizedBox(height: 32),
// Code Postal et Ville
FormFieldRow(
config: config,
fields: [
Flexible(
flex: 1,
Row(
children: [
Expanded(
flex: 2,
child: _buildField(
config: config,
label: 'Code Postal',
@ -443,8 +494,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled && !_sameAddress,
),
),
Flexible(
flex: 4,
const SizedBox(width: 20),
Expanded(
flex: 5,
child: _buildField(
config: config,
label: 'Ville',
@ -459,6 +511,86 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
);
}
/// Layout MOBILE : tous les champs empilés verticalement
Widget _buildMobileFields(BuildContext context, DisplayConfig config) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Nom
_buildField(
config: config,
label: 'Nom',
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
),
const SizedBox(height: 12),
// Prénom
_buildField(
config: config,
label: 'Prénom',
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
),
const SizedBox(height: 12),
// Téléphone
_buildField(
config: config,
label: 'Téléphone',
controller: _phoneController,
hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone,
enabled: _fieldsEnabled,
),
const SizedBox(height: 12),
// Email
_buildField(
config: config,
label: 'Email',
controller: _emailController,
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
),
const SizedBox(height: 12),
// Adresse
_buildField(
config: config,
label: 'Adresse (N° et Rue)',
controller: _addressController,
hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress,
),
const SizedBox(height: 12),
// Code Postal
_buildField(
config: config,
label: 'Code Postal',
controller: _postalCodeController,
hint: 'Code postal',
keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress,
),
const SizedBox(height: 12),
// Ville
_buildField(
config: config,
label: 'Ville',
controller: _cityController,
hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress,
),
],
);
}
/// Construit un champ individuel (éditable ou readonly)
Widget _buildField({
required DisplayConfig config,
@ -476,15 +608,16 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
value: controller.text,
);
} else {
// Mode éditable : utiliser CustomAppTextField existant pour garder le style
// Mode éditable : style adapté mobile/desktop
return CustomAppTextField(
controller: controller,
labelText: label,
hintText: hint ?? label,
style: CustomAppTextFieldStyle.beige,
fieldWidth: double.infinity,
labelFontSize: config.isMobile ? 18.0 : 22.0,
inputFontSize: config.isMobile ? 16.0 : 20.0,
fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0,
keyboardType: keyboardType ?? TextInputType.text,
enabled: enabled,
);