feat(#78): Créer infrastructure générique pour formulaires multi-modes

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 <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-03 17:33:29 +01:00
parent 5d7eb9eb36
commit 890619ff59
4 changed files with 757 additions and 0 deletions

View File

@ -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;
}
}
}

View File

@ -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<PersonalInfoFormScreen> {
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

View File

@ -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,
),
),
],
);
}
}

View File

@ -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<String>? 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<Widget> 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),
],
],
);
}
}
}