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:
parent
5d7eb9eb36
commit
890619ff59
106
frontend/lib/config/display_config.dart
Normal file
106
frontend/lib/config/display_config.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
frontend/lib/widgets/README_FORM_WIDGETS.md
Normal file
220
frontend/lib/widgets/README_FORM_WIDGETS.md
Normal 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
|
||||||
223
frontend/lib/widgets/base_form_screen.dart
Normal file
223
frontend/lib/widgets/base_form_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
frontend/lib/widgets/form_field_wrapper.dart
Normal file
208
frontend/lib/widgets/form_field_wrapper.dart
Normal 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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user