From a57993a90f1d414b88c89cd93dd97fbe0d602483 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 4 Feb 2026 11:01:43 +0100 Subject: [PATCH] feat(#78): Finaliser layout mobile responsive de PersonalInfoFormScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../lib/widgets/custom_navigation_button.dart | 87 +++++ .../widgets/personal_info_form_screen.dart | 363 ++++++++++++------ 2 files changed, 335 insertions(+), 115 deletions(-) create mode 100644 frontend/lib/widgets/custom_navigation_button.dart diff --git a/frontend/lib/widgets/custom_navigation_button.dart b/frontend/lib/widgets/custom_navigation_button.dart new file mode 100644 index 0000000..b132c1b --- /dev/null +++ b/frontend/lib/widgets/custom_navigation_button.dart @@ -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é + } + } +} diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 8f423d9..e8e109f 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -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 { 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 { ), ), ), + + // 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 { ), ), ], - // 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 { /// 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 { 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 { } } - 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 { /// 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 { 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 { 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 { ); } + /// 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 { 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, );