From 890619ff594a52b68bb329828a792be008f10499 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 3 Feb 2026 17:33:29 +0100 Subject: [PATCH] =?UTF-8?q?feat(#78):=20Cr=C3=A9er=20infrastructure=20g?= =?UTF-8?q?=C3=A9n=C3=A9rique=20pour=20formulaires=20multi-modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle architecture centralisée pour tous les formulaires : **Configuration centrale (display_config.dart):** - DisplayMode enum (editable/readonly) - LayoutType enum (mobile/desktop) - DisplayConfig class pour configuration complète - LayoutHelper avec utilitaires (détection, spacing, etc.) - Breakpoint: 600px (mobile < 600px reste toujours vertical) **Widgets génériques (form_field_wrapper.dart):** - FormFieldWrapper: champ auto-adaptatif (TextField ou Text readonly) - FormFieldRow: ligne responsive (horizontal desktop, vertical mobile) **Structure de page (base_form_screen.dart):** - BaseFormScreen: layout complet avec carte, boutons, navigation - Gestion auto des assets carte (horizontal/vertical selon layout) **Avantages:** ✅ Code unique pour editable + readonly + mobile + desktop ✅ Logique centralisée (aucune duplication) ✅ Héritage automatique via DisplayConfig propagé ✅ API simple et cohérente Prochaine étape: Migration des widgets existants Référence: #78 Co-authored-by: Cursor --- frontend/lib/config/display_config.dart | 106 +++++++++ frontend/lib/widgets/README_FORM_WIDGETS.md | 220 ++++++++++++++++++ frontend/lib/widgets/base_form_screen.dart | 223 +++++++++++++++++++ frontend/lib/widgets/form_field_wrapper.dart | 208 +++++++++++++++++ 4 files changed, 757 insertions(+) create mode 100644 frontend/lib/config/display_config.dart create mode 100644 frontend/lib/widgets/README_FORM_WIDGETS.md create mode 100644 frontend/lib/widgets/base_form_screen.dart create mode 100644 frontend/lib/widgets/form_field_wrapper.dart diff --git a/frontend/lib/config/display_config.dart b/frontend/lib/config/display_config.dart new file mode 100644 index 0000000..7085bec --- /dev/null +++ b/frontend/lib/config/display_config.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +/// Mode d'affichage d'un formulaire +enum DisplayMode { + /// Mode éditable (formulaire d'inscription) + editable, + + /// Mode lecture seule (récapitulatif) + readonly, +} + +/// Configuration d'affichage pour les widgets de formulaire +class DisplayConfig { + /// Mode d'affichage (editable/readonly) + final DisplayMode mode; + + /// Type de layout détecté (mobile/desktop) + final LayoutType layoutType; + + const DisplayConfig({ + required this.mode, + required this.layoutType, + }); + + /// Crée une config à partir du contexte + factory DisplayConfig.fromContext( + BuildContext context, { + required DisplayMode mode, + }) { + return DisplayConfig( + mode: mode, + layoutType: LayoutHelper.getLayoutType(context), + ); + } + + /// Est en mode éditable + bool get isEditable => mode == DisplayMode.editable; + + /// Est en mode lecture seule + bool get isReadonly => mode == DisplayMode.readonly; + + /// Est en layout mobile + bool get isMobile => layoutType == LayoutType.mobile; + + /// Est en layout desktop + bool get isDesktop => layoutType == LayoutType.desktop; + + /// Layout vertical (mobile) + bool get isVerticalLayout => isMobile; + + /// Layout horizontal (desktop) + bool get isHorizontalLayout => isDesktop; +} + +/// Type de layout +enum LayoutType { + /// Mobile (< 600px) - toujours vertical + mobile, + + /// Desktop/Tablette (≥ 600px) - horizontal + desktop, +} + +/// Utilitaires pour la détection de layout +class LayoutHelper { + /// Seuil de largeur pour mobile/desktop (en pixels) + static const double mobileBreakpoint = 600.0; + + /// Détermine le type de layout selon la largeur d'écran + static LayoutType getLayoutType(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return width < mobileBreakpoint + ? LayoutType.mobile + : LayoutType.desktop; + } + + /// Vérifie si on est sur mobile + static bool isMobile(BuildContext context) { + return getLayoutType(context) == LayoutType.mobile; + } + + /// Vérifie si on est sur desktop + static bool isDesktop(BuildContext context) { + return getLayoutType(context) == LayoutType.desktop; + } + + /// Retourne un espacement adapté au layout + static double getSpacing(BuildContext context, { + double mobileSpacing = 12.0, + double desktopSpacing = 20.0, + }) { + return isMobile(context) ? mobileSpacing : desktopSpacing; + } + + /// Retourne une largeur max adaptée au layout + static double getMaxWidth(BuildContext context, { + double? mobileMaxWidth, + double? desktopMaxWidth, + }) { + if (isMobile(context)) { + return mobileMaxWidth ?? double.infinity; + } else { + return desktopMaxWidth ?? 1200.0; + } + } +} diff --git a/frontend/lib/widgets/README_FORM_WIDGETS.md b/frontend/lib/widgets/README_FORM_WIDGETS.md new file mode 100644 index 0000000..0ee8d47 --- /dev/null +++ b/frontend/lib/widgets/README_FORM_WIDGETS.md @@ -0,0 +1,220 @@ +# Infrastructure générique pour les formulaires + +## 📋 Vue d'ensemble + +Cette infrastructure permet de créer des formulaires qui s'adaptent automatiquement : +- **Mode éditable** (inscription) vs **lecture seule** (récapitulatif) +- **Layout mobile** (vertical, < 600px) vs **desktop** (horizontal, ≥ 600px) +- **Mobile reste toujours vertical**, même en rotation paysage + +## 🏗️ Architecture + +### 1. `display_config.dart` - Configuration centrale + +```dart +// Mode d'affichage +enum DisplayMode { + editable, // Formulaire éditable + readonly, // Récapitulatif +} + +// Type de layout +enum LayoutType { + mobile, // < 600px, toujours vertical + desktop, // ≥ 600px, horizontal +} + +// Configuration complète +DisplayConfig config = DisplayConfig.fromContext( + context, + mode: DisplayMode.editable, +); +``` + +### 2. `form_field_wrapper.dart` - Champs génériques + +#### FormFieldWrapper +Widget pour afficher un champ unique qui s'adapte automatiquement. + +**Mode éditable :** +```dart +FormFieldWrapper( + config: config, + label: 'Prénom', + value: '', + controller: firstNameController, + onChanged: (value) => {}, + hint: 'Entrez votre prénom', +) +``` + +**Mode readonly :** +```dart +FormFieldWrapper( + config: config, + label: 'Prénom', + value: 'Jean', +) +``` + +#### FormFieldRow +Widget pour afficher plusieurs champs sur une ligne (desktop) ou en colonne (mobile). + +```dart +FormFieldRow( + config: config, + fields: [ + FormFieldWrapper(...), + FormFieldWrapper(...), + ], +) +``` + +### 3. `base_form_screen.dart` - Structure de page générique + +Encapsule toute la structure d'une page de formulaire : +- En-tête (étape + titre) +- Carte avec fond adapté (horizontal/vertical) +- Boutons de navigation +- Gestion automatique du layout + +```dart +BaseFormScreen( + config: DisplayConfig.fromContext( + context, + mode: DisplayMode.editable, + ), + stepText: 'Étape 1/4', + title: 'Informations personnelles', + cardColor: CardColorHorizontal.blue, + previousRoute: '/previous', + onSubmit: () => _handleSubmit(), + content: Column( + children: [ + FormFieldRow( + config: config, + fields: [ + FormFieldWrapper(...), + FormFieldWrapper(...), + ], + ), + ], + ), +) +``` + +## 📱 Comportement responsive + +### Breakpoint : 600px + +| Largeur écran | LayoutType | Orientation carte | Disposition champs | +|--------------|------------|-------------------|-------------------| +| < 600px | mobile | Verticale | Colonne | +| ≥ 600px | desktop | Horizontale | Ligne | + +### Règle importante +**Sur mobile, le layout reste TOUJOURS vertical**, même si l'utilisateur tourne son téléphone en mode paysage. + +## 🎨 Utilisation dans un widget de formulaire + +### Exemple : PersonalInfoFormScreen + +```dart +class PersonalInfoFormScreen extends StatefulWidget { + final DisplayMode mode; + final PersonalInfoData? initialData; + final Function(PersonalInfoData) onSubmit; + + // ... +} + +class _PersonalInfoFormScreenState extends State { + late TextEditingController _firstNameController; + late TextEditingController _lastNameController; + + @override + Widget build(BuildContext context) { + final config = DisplayConfig.fromContext( + context, + mode: widget.mode, + ); + + return BaseFormScreen( + config: config, + stepText: 'Étape 1/4', + title: 'Informations personnelles', + cardColor: CardColorHorizontal.blue, + previousRoute: '/previous', + onSubmit: _handleSubmit, + content: Column( + children: [ + FormFieldRow( + config: config, + fields: [ + FormFieldWrapper( + config: config, + label: 'Prénom', + value: _firstNameController.text, + controller: config.isEditable ? _firstNameController : null, + onChanged: config.isEditable ? (v) => setState(() {}) : null, + ), + FormFieldWrapper( + config: config, + label: 'Nom', + value: _lastNameController.text, + controller: config.isEditable ? _lastNameController : null, + onChanged: config.isEditable ? (v) => setState(() {}) : null, + ), + ], + ), + ], + ), + ); + } + + void _handleSubmit() { + final data = PersonalInfoData( + firstName: _firstNameController.text, + lastName: _lastNameController.text, + ); + widget.onSubmit(data); + } +} +``` + +## ✅ Avantages + +1. **Code unique** : Un seul widget pour éditable + readonly + mobile + desktop +2. **Cohérence** : Tous les formulaires se comportent de la même façon +3. **Maintenance** : Modification centralisée de l'UI +4. **Performance** : Pas de rebuild inutile, layout déterminé au build +5. **Simplicité** : API claire et prévisible + +## 🔧 Utilitaires disponibles + +```dart +// Détecter le type de layout +bool isMobile = LayoutHelper.isMobile(context); +bool isDesktop = LayoutHelper.isDesktop(context); + +// Espacement adaptatif +double spacing = LayoutHelper.getSpacing( + context, + mobileSpacing: 12.0, + desktopSpacing: 20.0, +); + +// Largeur max adaptative +double maxWidth = LayoutHelper.getMaxWidth(context); +``` + +## 🚀 Migration des widgets existants + +Pour migrer un widget existant vers cette infrastructure : + +1. Ajouter paramètre `DisplayMode mode` +2. Créer `DisplayConfig.fromContext(context, mode: widget.mode)` +3. Remplacer la structure Scaffold par `BaseFormScreen` +4. Remplacer les champs par `FormFieldWrapper` +5. Grouper les champs avec `FormFieldRow` +6. Tester en mode editable + readonly + mobile + desktop diff --git a/frontend/lib/widgets/base_form_screen.dart b/frontend/lib/widgets/base_form_screen.dart new file mode 100644 index 0000000..d97fc53 --- /dev/null +++ b/frontend/lib/widgets/base_form_screen.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../config/display_config.dart'; +import '../models/card_assets.dart'; +import 'hover_relief_widget.dart'; +import 'image_button.dart'; + +/// Widget de base générique pour tous les écrans de formulaire +/// Gère automatiquement le layout, les boutons de navigation, etc. +class BaseFormScreen extends StatelessWidget { + /// Configuration d'affichage + final DisplayConfig config; + + /// Texte de l'étape (ex: "Étape 1/4") + final String stepText; + + /// Titre du formulaire + final String title; + + /// Couleur de la carte (horizontal pour desktop) + final CardColorHorizontal cardColor; + + /// Contenu du formulaire + final Widget content; + + /// Texte du bouton de soumission (par défaut "Suivant") + final String? submitButtonText; + + /// Callback de soumission + final VoidCallback onSubmit; + + /// Route précédente (pour le bouton retour) + final String previousRoute; + + /// Widget supplémentaire au-dessus du contenu (ex: toggle) + final Widget? headerWidget; + + /// Widget supplémentaire en dessous du contenu (ex: checkbox CGU) + final Widget? footerWidget; + + /// Padding personnalisé pour le contenu + final EdgeInsets? contentPadding; + + const BaseFormScreen({ + super.key, + required this.config, + required this.stepText, + required this.title, + required this.cardColor, + required this.content, + required this.onSubmit, + required this.previousRoute, + this.submitButtonText, + this.headerWidget, + this.footerWidget, + this.contentPadding, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFFF8E1), + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.all( + LayoutHelper.getSpacing(context, + mobileSpacing: 16.0, + desktopSpacing: 32.0, + ), + ), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: LayoutHelper.getMaxWidth(context), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Texte de l'étape + Text( + stepText, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 14 : 16, + fontWeight: FontWeight.w500, + color: const Color(0xFF6D4C41), + ), + ), + const SizedBox(height: 8), + + // Titre + Text( + title, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 24 : 32, + fontWeight: FontWeight.bold, + color: const Color(0xFF4A4A4A), + ), + ), + const SizedBox(height: 24), + + // Header widget (si fourni) + if (headerWidget != null) ...[ + headerWidget!, + const SizedBox(height: 16), + ], + + // Carte principale + _buildCard(context), + + const SizedBox(height: 24), + + // Footer widget (si fourni) + if (footerWidget != null) ...[ + footerWidget!, + const SizedBox(height: 24), + ], + + // Boutons de navigation + _buildNavigationButtons(context), + ], + ), + ), + ), + ), + ), + ); + } + + /// Construit la carte principale + Widget _buildCard(BuildContext context) { + final effectivePadding = contentPadding ?? + EdgeInsets.all( + LayoutHelper.getSpacing(context, + mobileSpacing: 16.0, + desktopSpacing: 32.0, + ), + ); + + if (config.isMobile) { + // Carte verticale sur mobile + return Container( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(_getVerticalCardAsset()), + fit: BoxFit.fill, + ), + ), + child: Padding( + padding: effectivePadding, + child: content, + ), + ); + } else { + // Carte horizontale sur desktop + return Container( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(cardColor.path), + fit: BoxFit.fill, + ), + ), + child: Padding( + padding: effectivePadding, + child: content, + ), + ); + } + } + + /// Retourne l'asset de carte vertical correspondant à la couleur + String _getVerticalCardAsset() { + // Mapping couleur horizontale -> verticale + switch (cardColor) { + case CardColorHorizontal.blue: + return CardColorVertical.blue.path; + case CardColorHorizontal.green: + return CardColorVertical.green.path; + case CardColorHorizontal.lavender: + return CardColorVertical.lavender.path; + case CardColorHorizontal.lime: + return CardColorVertical.lime.path; + case CardColorHorizontal.peach: + return CardColorVertical.peach.path; + case CardColorHorizontal.pink: + return CardColorVertical.pink.path; + case CardColorHorizontal.red: + return CardColorVertical.red.path; + } + } + + /// Construit les boutons de navigation + Widget _buildNavigationButtons(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Bouton Précédent + HoverReliefWidget( + child: ImageButton( + imagePath: 'assets/images/btn_green.png', + text: 'Précédent', + onPressed: () => Navigator.pushNamed(context, previousRoute), + width: config.isMobile ? 120 : 150, + height: config.isMobile ? 40 : 50, + ), + ), + + // Bouton Suivant/Soumettre + HoverReliefWidget( + child: ImageButton( + imagePath: 'assets/images/btn_green.png', + text: submitButtonText ?? 'Suivant', + onPressed: config.isReadonly ? onSubmit : () { + // En mode éditable, valider avant de soumettre + onSubmit(); + }, + width: config.isMobile ? 120 : 150, + height: config.isMobile ? 40 : 50, + ), + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/form_field_wrapper.dart b/frontend/lib/widgets/form_field_wrapper.dart new file mode 100644 index 0000000..ed601c7 --- /dev/null +++ b/frontend/lib/widgets/form_field_wrapper.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../config/display_config.dart'; +import 'custom_app_text_field.dart'; + +/// Widget générique pour afficher un champ de formulaire +/// S'adapte automatiquement selon le DisplayConfig (editable/readonly, mobile/desktop) +class FormFieldWrapper extends StatelessWidget { + /// Configuration d'affichage + final DisplayConfig config; + + /// Label du champ + final String label; + + /// Valeur actuelle + final String value; + + /// Controller pour le mode éditable + final TextEditingController? controller; + + /// Callback de changement (mode éditable) + final ValueChanged? onChanged; + + /// Hint du champ (mode éditable) + final String? hint; + + /// Nombre de lignes (pour textarea) + final int? maxLines; + + /// Type de clavier + final TextInputType? keyboardType; + + /// Widget personnalisé à afficher (override le champ standard) + final Widget? customWidget; + + const FormFieldWrapper({ + super.key, + required this.config, + required this.label, + required this.value, + this.controller, + this.onChanged, + this.hint, + this.maxLines, + this.keyboardType, + this.customWidget, + }); + + @override + Widget build(BuildContext context) { + if (config.isReadonly) { + return _buildReadonlyField(context); + } else { + return _buildEditableField(context); + } + } + + /// Construit un champ en mode lecture seule + Widget _buildReadonlyField(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: LayoutHelper.getSpacing(context, + mobileSpacing: 8.0, + desktopSpacing: 12.0, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label + Text( + label, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 14 : 16, + fontWeight: FontWeight.bold, + color: const Color(0xFF4A4A4A), + ), + ), + const SizedBox(height: 6), + + // Valeur + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFFE0E0E0), + width: 1, + ), + ), + child: Text( + value.isEmpty ? '-' : value, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 14 : 16, + color: value.isEmpty + ? const Color(0xFFBDBDBD) + : const Color(0xFF2C2C2C), + ), + ), + ), + ], + ), + ); + } + + /// Construit un champ en mode éditable + Widget _buildEditableField(BuildContext context) { + // Si un widget personnalisé est fourni, l'utiliser + if (customWidget != null) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: LayoutHelper.getSpacing(context, + mobileSpacing: 8.0, + desktopSpacing: 12.0, + ), + ), + child: customWidget, + ); + } + + // Sinon, utiliser le champ standard + return Padding( + padding: EdgeInsets.symmetric( + vertical: LayoutHelper.getSpacing(context, + mobileSpacing: 8.0, + desktopSpacing: 12.0, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label + Text( + label, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 14 : 16, + fontWeight: FontWeight.w600, + color: const Color(0xFF4A4A4A), + ), + ), + const SizedBox(height: 8), + + // Champ de saisie + CustomAppTextField( + controller: controller!, + hintText: hint ?? label, + onChanged: onChanged, + maxLines: maxLines ?? 1, + keyboardType: keyboardType, + ), + ], + ), + ); + } +} + +/// Widget générique pour afficher une ligne de champs +/// S'adapte automatiquement: horizontal sur desktop, vertical sur mobile +class FormFieldRow extends StatelessWidget { + /// Configuration d'affichage + final DisplayConfig config; + + /// Liste des champs à afficher + final List fields; + + /// Espacement entre les champs + final double? spacing; + + const FormFieldRow({ + super.key, + required this.config, + required this.fields, + this.spacing, + }); + + @override + Widget build(BuildContext context) { + final effectiveSpacing = spacing ?? + LayoutHelper.getSpacing(context, + mobileSpacing: 12.0, + desktopSpacing: 20.0, + ); + + if (config.isMobile) { + // Layout vertical sur mobile + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: fields, + ); + } else { + // Layout horizontal sur desktop + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < fields.length; i++) ...[ + Expanded(child: fields[i]), + if (i < fields.length - 1) SizedBox(width: effectiveSpacing), + ], + ], + ); + } + } +}