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