petitspas/frontend/lib/widgets/personal_info_form_screen.dart
Julien Martin d550e57cb8 fix(inscription-am): étape 1/2 obligatoires alignées API (min 2 car., photo)
- Étape 1 : prénom/nom min 2 caractères (minPersonNameLength sur PersonalInfoFormScreen pour AM)

- Étape 2 : ville/pays naissance min 2 ; photo réelle + consentement obligatoires ; isStep2Complete strict

- registerAM : lieu naissance trim + toujours envoyer photo_base64 ; lieu_* non null si valide

- Étape 4 : blocage envoi si isRegistrationComplete faux

Made-with: Cursor
2026-04-13 13:32:25 +02:00

1308 lines
45 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:go_router/go_router.dart';
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';
/// Modèle de données pour le formulaire
class PersonalInfoData {
String firstName;
String lastName;
String phone;
String email;
String address;
String postalCode;
String city;
PersonalInfoData({
this.firstName = '',
this.lastName = '',
this.phone = '',
this.email = '',
this.address = '',
this.postalCode = '',
this.city = '',
});
}
/// Widget générique pour les formulaires d'informations personnelles
/// Supporte mode éditable et readonly, responsive mobile/desktop
class PersonalInfoFormScreen extends StatefulWidget {
final DisplayMode mode; // editable ou readonly
final String stepText; // Ex: "Étape 1/5"
final String title; // Ex: "Informations du Parent Principal"
final CardColorHorizontal cardColor;
final PersonalInfoData initialData;
final Function(PersonalInfoData data, {bool? hasSecondPerson, bool? sameAddress}) onSubmit;
final String previousRoute;
// Options spécifiques pour Parent 2
final bool showSecondPersonToggle; // Afficher "Il y a un 2ème parent"
final bool? initialHasSecondPerson;
final bool showSameAddressCheckbox; // Afficher "Même adresse que parent 1"
final bool? initialSameAddress;
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché)
/// Si non null (ex. 2 pour inscription AM), nom/prénom : trim + longueur minimale (aligné API).
final int? minPersonNameLength;
const PersonalInfoFormScreen({
super.key,
this.mode = DisplayMode.editable,
required this.stepText,
required this.title,
required this.cardColor,
required this.initialData,
required this.onSubmit,
required this.previousRoute,
this.showSecondPersonToggle = false,
this.initialHasSecondPerson,
this.showSameAddressCheckbox = false,
this.initialSameAddress,
this.referenceAddressData,
this.embedContentOnly = false,
this.onEdit,
this.minPersonNameLength,
});
@override
State<PersonalInfoFormScreen> createState() => _PersonalInfoFormScreenState();
}
class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
/// Ordre de tabulation explicite (étape 1 / parent 2, etc.)
static const double _focusSecondPersonToggle = 1;
static const double _focusSameAddressToggle = 2;
static const double _focusLastName = 10;
static const double _focusFirstName = 11;
static const double _focusPhone = 12;
static const double _focusEmail = 13;
static const double _focusAddress = 14;
static const double _focusPostal = 15;
static const double _focusCity = 16;
static const double _focusNavPrevious = 100;
static const double _focusNavNext = 101;
final _formKey = GlobalKey<FormState>();
late TextEditingController _lastNameController;
late TextEditingController _firstNameController;
late TextEditingController _phoneController;
late TextEditingController _emailController;
late TextEditingController _addressController;
late TextEditingController _postalCodeController;
late TextEditingController _cityController;
bool _hasSecondPerson = false;
bool _sameAddress = false;
bool _fieldsEnabled = true;
FocusNode? _lastNameFocus;
FocusNode? _firstNameFocus;
FocusNode? _cityFocus;
FocusNode? _emailFocus;
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
@override
void initState() {
super.initState();
_lastNameController = TextEditingController(text: widget.initialData.lastName);
_firstNameController = TextEditingController(text: widget.initialData.firstName);
_phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone));
_emailController = TextEditingController(
text: normalizeEmailText(widget.initialData.email),
);
_addressController = TextEditingController(text: widget.initialData.address);
_postalCodeController = TextEditingController(text: widget.initialData.postalCode);
_cityController = TextEditingController(text: widget.initialData.city);
if (widget.showSecondPersonToggle) {
_hasSecondPerson = widget.initialHasSecondPerson ?? true;
_fieldsEnabled = _hasSecondPerson;
}
if (widget.showSameAddressCheckbox) {
_sameAddress = widget.initialSameAddress ?? false;
_updateAddressFields();
}
if (widget.mode == DisplayMode.editable) {
_lastNameFocus = FocusNode();
_firstNameFocus = FocusNode();
_cityFocus = FocusNode();
_emailFocus = FocusNode();
_lastNameFocus!.addListener(_onLastNameFocusChange);
_firstNameFocus!.addListener(_onFirstNameFocusChange);
_cityFocus!.addListener(_onCityFocusChange);
_emailFocus!.addListener(_onEmailFocusChange);
}
}
void _onLastNameFocusChange() {
if (_lastNameFocus == null || _lastNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_lastNameController);
}
void _onFirstNameFocusChange() {
if (_firstNameFocus == null || _firstNameFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_firstNameController);
}
void _onCityFocusChange() {
if (_cityFocus == null || _cityFocus!.hasFocus) {
return;
}
_applyPersonNameFormat(_cityController);
}
void _onEmailFocusChange() {
if (_emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
// Reporter normalisation + validate au frame suivant pour ne pas casser Tab.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _emailFocus == null || _emailFocus!.hasFocus) {
return;
}
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return;
}
final normalized = normalizeEmailText(_emailController.text);
if (normalized != _emailController.text) {
_emailController.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
// Pas derreur « obligatoire » au blur si le champ est encore vide :
// évite un message dès lactivation du parent 2 (focus / rebuild) et
// la soumission du formulaire valide toujours.
if (normalizeEmailText(_emailController.text).isEmpty) {
return;
}
_emailFormKey.currentState?.validate();
});
}
String? _validateRequiredFrenchPhone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return validateFrenchNationalPhone(value, allowEmpty: false);
}
String? _validateRequiredEmail(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire.';
}
return validateEmail(value, allowEmpty: true);
}
/// Prénom / nom avec longueur minimale (ex. inscription AM, aligné `MinLength` API).
String? _validatePersonNameWithMinLength(String? value) {
final min = widget.minPersonNameLength;
if (min == null) return null;
final t = (value ?? '').trim();
if (t.isEmpty) return 'Ce champ est obligatoire';
if (t.length < min) return 'Au moins $min caractères';
return null;
}
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredFrenchPhone(value);
}
String? _validateEmailIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
return null;
}
return _validateRequiredEmail(value);
}
/// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1).
String? _validateAddressLineIfManualEntry(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'Ce champ est obligatoire';
}
return null;
}
/// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ».
String? _validatePostalCodeField(String? value) {
if (!_fieldsEnabled) {
return null;
}
if (widget.showSameAddressCheckbox && _sameAddress) {
return null;
}
return validateFrenchPostalCode(value, allowEmpty: false);
}
void _applyPersonNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) {
return;
}
controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
@override
void dispose() {
_lastNameFocus?.removeListener(_onLastNameFocusChange);
_firstNameFocus?.removeListener(_onFirstNameFocusChange);
_cityFocus?.removeListener(_onCityFocusChange);
_emailFocus?.removeListener(_onEmailFocusChange);
_lastNameFocus?.dispose();
_firstNameFocus?.dispose();
_cityFocus?.dispose();
_emailFocus?.dispose();
_lastNameController.dispose();
_firstNameController.dispose();
_phoneController.dispose();
_emailController.dispose();
_addressController.dispose();
_postalCodeController.dispose();
_cityController.dispose();
super.dispose();
}
void _updateAddressFields() {
if (_sameAddress && widget.referenceAddressData != null) {
_addressController.text = widget.referenceAddressData!.address;
_postalCodeController.text = widget.referenceAddressData!.postalCode;
_cityController.text = widget.referenceAddressData!.city;
}
}
Widget _wrapScrollBodyIfEditable({
required DisplayConfig config,
required Widget child,
}) {
if (!config.isEditable) return child;
return FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: child,
);
}
Widget _orderedFocus({
required bool enabled,
required double order,
required Widget child,
}) {
if (!enabled) return child;
return FocusTraversalOrder(
order: NumericFocusOrder(order),
child: child,
);
}
void _handleSubmit() {
if (widget.mode == DisplayMode.editable) {
_applyPersonNameFormat(_lastNameController);
_applyPersonNameFormat(_firstNameController);
_applyPersonNameFormat(_cityController);
}
// Parent 2 désactivé : pas de contrôle sur les champs (désactivés à lécran).
if (widget.showSecondPersonToggle && !_hasSecondPerson) {
widget.onSubmit(
PersonalInfoData(),
hasSecondPerson: false,
sameAddress: widget.showSameAddressCheckbox ? _sameAddress : null,
);
return;
}
if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) {
final data = PersonalInfoData(
firstName: _firstNameController.text,
lastName: _lastNameController.text,
phone: normalizePhone(_phoneController.text),
email: normalizeEmailText(_emailController.text),
address: _addressController.text,
postalCode: _postalCodeController.text,
city: _cityController.text,
);
widget.onSubmit(
data,
hasSecondPerson: widget.showSecondPersonToggle ? _hasSecondPerson : null,
sameAddress: widget.showSameAddressCheckbox ? _sameAddress : null,
);
}
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final config = DisplayConfig.fromContext(context, mode: widget.mode);
if (widget.embedContentOnly) {
return _buildCard(context, config, screenSize);
}
return Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.0),
child: _wrapScrollBodyIfEditable(
config: config,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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: config.isMobile ? 18 : 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: config.isMobile ? 16 : 30),
_buildCard(context, config, screenSize),
if (config.isMobile) ...[
const SizedBox(height: 20),
Padding(
padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05,
),
child: Row(
children: [
Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavPrevious,
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),
Expanded(
child: _orderedFocus(
enabled: config.isEditable,
order: _focusNavNext,
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 (desktop uniquement)
if (!config.isMobile) ...[
Positioned(
top: screenSize.height / 2 - 20,
left: 40,
child: IconButton(
icon: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(math.pi),
child: Image.asset('assets/images/chevron_right.png', height: 40),
),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
tooltip: 'Retour',
),
),
Positioned(
top: screenSize.height / 2 - 20,
right: 40,
child: IconButton(
icon: Image.asset('assets/images/chevron_right.png', height: 40),
onPressed: _handleSubmit,
tooltip: 'Suivant',
),
),
],
],
),
);
}
Widget _buildCard(BuildContext context, DisplayConfig config, Size screenSize) {
// En mode readonly desktop avec embedContentOnly, utiliser le layout ancien (AspectRatio 2:1)
if (config.isReadonly && !config.isMobile && widget.embedContentOnly) {
return _buildReadonlyDesktopCard(context, config, screenSize);
}
// En mode readonly mobile avec embedContentOnly, forcer le ratio 1:2 (Vertical)
if (config.isReadonly && config.isMobile && widget.embedContentOnly) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05), // Marge 5%
child: _buildMobileReadonlyCard(context, config, screenSize),
);
}
// Mode normal (éditable ou mobile non-récap)
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
padding: EdgeInsets.symmetric(
vertical: config.isMobile ? 20 : (config.isReadonly ? 30 : 50),
horizontal: config.isMobile ? 24 : 50,
),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(
config.isMobile
? _getVerticalCardAsset()
: widget.cardColor.path
),
fit: BoxFit.fill,
),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.embedContentOnly) ...[
Text(
// Titre raccourci sur mobile en mode readonly pour laisser place au bouton edit
(config.isMobile && config.isReadonly && widget.title.contains('Parent Principal'))
? 'Parent Principal'
: widget.title,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 18 : 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
],
if (config.isEditable && widget.showSecondPersonToggle)
_buildToggles(context, config),
_buildFormFields(context, config),
],
),
),
),
if (config.isReadonly && widget.onEdit != null)
Positioned(
top: 10,
right: 10,
child: IconButton(
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
),
],
);
}
/// Carte en mode readonly MOBILE avec hauteur adaptative
Widget _buildMobileReadonlyCard(BuildContext context, DisplayConfig config, Size screenSize) {
return Container(
width: double.infinity,
// Pas de height fixe, s'adapte au contenu
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 24.0),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(_getVerticalCardAsset()),
fit: BoxFit.fill, // Fill pour que l'image s'étire selon la hauteur du contenu
),
borderRadius: BorderRadius.circular(15),
),
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min, // Important : prend le min de place
children: [
// Titre + Edit Button
Row(
children: [
Expanded(
child: Text(
(widget.title.contains('Parent Principal') ? 'Parent Principal' : (widget.title.contains('Deuxième Parent') ? 'Deuxième Parent' : widget.title)),
style: GoogleFonts.merienda(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
textAlign: TextAlign.center,
),
),
if (widget.onEdit != null)
const SizedBox(width: 28),
],
),
// Contenu aligné en haut (plus d'Expanded ici car parent non contraint)
Padding(
padding: const EdgeInsets.only(top: 20.0),
child: _buildFormFields(context, config),
),
],
),
if (widget.onEdit != null)
Positioned(
top: 0,
right: 0,
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: const Icon(Icons.edit, color: Colors.black54, size: 24),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
),
],
),
);
}
/// Carte en mode readonly desktop avec AspectRatio 2:1 (format de l'ancien récapitulatif)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Largeur de la carte : 50% de l'écran (comme l'ancien système)
final cardWidth = screenSize.width / 2.0;
return SizedBox(
width: cardWidth,
child: AspectRatio(
aspectRatio: 2.0,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(widget.cardColor.path),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(15),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre centré
Align(
alignment: Alignment.center,
child: Text(
widget.title,
style: GoogleFonts.merienda(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
const SizedBox(height: 55), // Espace très augmenté pour pousser les champs vers le bas
// Champs du formulaire
_buildFormFields(context, config),
],
),
),
// Bouton d'édition à droite
if (widget.onEdit != null)
IconButton(
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
],
),
),
),
);
}
/// Construit les toggles (Parent 2 / Même adresse)
Widget _buildToggles(BuildContext context, DisplayConfig config) {
if (config.isMobile) {
// Layout vertical sur mobile - toggles compacts
return Column(
children: [
_buildSecondPersonToggle(context, config),
if (widget.showSameAddressCheckbox) ...[
const SizedBox(height: 5), // Réduit de 12 à 5
_buildSameAddressToggle(context, config),
],
const SizedBox(height: 10), // Réduit de 24 à 10
],
);
} else {
// Layout horizontal sur desktop
return Column(
children: [
Row(
children: [
Expanded(
flex: 12,
child: _buildSecondPersonToggle(context, config),
),
const Expanded(flex: 1, child: SizedBox()),
if (widget.showSameAddressCheckbox)
Expanded(
flex: 12,
child: _buildSameAddressToggle(context, config),
),
],
),
const SizedBox(height: 32),
],
);
}
}
Widget _buildSecondPersonToggle(BuildContext context, DisplayConfig config) {
return Row(
children: [
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,
fontSize: config.isMobile ? 14 : 16,
),
overflow: TextOverflow.ellipsis,
),
),
_orderedFocus(
enabled: config.isEditable,
order: _focusSecondPersonToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _hasSecondPerson,
onChanged: (value) {
setState(() {
_hasSecondPerson = value;
_fieldsEnabled = value;
if (value && _sameAddress) {
_updateAddressFields();
}
});
if (!value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Si lutilisateur a déjà recoché Parent 2, ne pas valider :
// sinon des champs vides affichent une erreur tout de suite.
if (!mounted || _hasSecondPerson) {
return;
}
_formKey.currentState?.validate();
});
}
},
activeColor: Theme.of(context).primaryColor,
),
),
),
],
);
}
Widget _buildSameAddressToggle(BuildContext context, DisplayConfig config) {
return Row(
children: [
Icon(
Icons.home_work_outlined,
size: config.isMobile ? 18 : 20,
color: _fieldsEnabled ? null : Colors.grey,
),
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,
),
),
_orderedFocus(
enabled: config.isEditable,
order: _focusSameAddressToggle,
child: Transform.scale(
scale: config.isMobile ? 0.85 : 1.0,
child: Switch(
value: _sameAddress,
onChanged: _fieldsEnabled ? (value) {
setState(() {
_sameAddress = value;
_updateAddressFields();
});
} : null,
activeColor: Theme.of(context).primaryColor,
),
),
),
],
);
}
/// 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) {
// En mode readonly, utiliser l'ancien layout du récapitulatif
if (config.isReadonly) {
return _buildReadonlyDesktopFields(context);
}
// Mode éditable : layout normal
final double verticalSpacing = 32.0;
return Column(
children: [
// Nom et Prénom
Row(
children: [
Expanded(
child: _buildField(
config: config,
label: 'Nom',
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
),
),
const SizedBox(width: 20),
Expanded(
child: _buildField(
config: config,
label: 'Prénom',
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
),
),
],
),
SizedBox(height: verticalSpacing),
// Téléphone et Email
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,
inputFormatters: frenchPhoneInputFormatters,
focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
),
),
const SizedBox(width: 20),
Expanded(
child: _buildField(
config: config,
label: 'Email',
controller: _emailController,
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
),
),
],
),
SizedBox(height: verticalSpacing),
// Adresse
_buildField(
config: config,
label: 'Adresse (N° et Rue)',
controller: _addressController,
hint: 'Numéro et nom de votre rue',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
SizedBox(height: verticalSpacing),
// Code Postal et Ville
Row(
children: [
Expanded(
flex: 2,
child: _buildField(
config: config,
label: 'Code Postal',
controller: _postalCodeController,
hint: '5 chiffres',
keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
),
),
const SizedBox(width: 20),
Expanded(
flex: 5,
child: _buildField(
config: config,
label: 'Ville',
controller: _cityController,
hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
),
],
),
],
);
}
/// Layout DESKTOP en mode READONLY : réplique l'ancien design du récapitulatif
Widget _buildReadonlyDesktopFields(BuildContext context) {
const double verticalSpacing = 20.0; // Réduit pour compacter le bas
const double labelFontSize = 22.0;
const double valueFontSize = 18.0; // Taille du texte dans les champs
return Column(
children: [
// Ligne 1 : Nom + Prénom
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildDisplayFieldValue(
context,
'Nom :',
_lastNameController.text,
labelFontSize: labelFontSize,
valueFontSize: valueFontSize,
),
),
const SizedBox(width: 20),
Expanded(
child: _buildDisplayFieldValue(
context,
'Prénom :',
_firstNameController.text,
labelFontSize: labelFontSize,
valueFontSize: valueFontSize,
),
),
],
),
const SizedBox(height: verticalSpacing),
// Ligne 2 : Téléphone + Email
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildDisplayFieldValue(
context,
'Téléphone :',
_phoneController.text.trim().isNotEmpty
? formatPhoneForDisplay(_phoneController.text)
: _phoneController.text,
labelFontSize: labelFontSize,
valueFontSize: valueFontSize,
),
),
const SizedBox(width: 20),
Expanded(
child: _buildDisplayFieldValue(
context,
'Email :',
_emailController.text,
multiLine: true,
labelFontSize: labelFontSize,
valueFontSize: valueFontSize,
),
),
],
),
const SizedBox(height: verticalSpacing),
// Ligne 3 : Adresse complète (adresse + CP + ville)
_buildDisplayFieldValue(
context,
'Adresse :',
"${_addressController.text}\n${_postalCodeController.text} ${_cityController.text}".trim(),
multiLine: true,
fieldHeight: 80,
labelFontSize: labelFontSize,
valueFontSize: valueFontSize,
),
],
);
}
/// Helper pour afficher un champ en lecture seule avec le style de l'ancien récap
Widget _buildDisplayFieldValue(
BuildContext context,
String label,
String value, {
bool multiLine = false,
double fieldHeight = 50.0,
double labelFontSize = 18.0,
double valueFontSize = 18.0, // Taille du texte dans le champ
}) {
const FontWeight labelFontWeight = FontWeight.w600;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.merienda(
fontSize: labelFontSize,
fontWeight: labelFontWeight,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
height: multiLine ? null : fieldHeight,
constraints: multiLine ? const BoxConstraints(minHeight: 50.0) : null,
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 12.0),
decoration: BoxDecoration(
image: const DecorationImage(
image: AssetImage('assets/images/bg_beige.png'),
fit: BoxFit.fill,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
value.isNotEmpty ? value : '-',
style: GoogleFonts.merienda(fontSize: valueFontSize),
maxLines: multiLine ? null : 1,
overflow: multiLine ? TextOverflow.visible : TextOverflow.ellipsis,
),
),
],
);
}
/// Layout MOBILE : tous les champs empilés verticalement
Widget _buildMobileFields(BuildContext context, DisplayConfig config) {
// Mode Readonly Mobile : Layout compact et groupé
if (config.isReadonly) {
// NOTE: FormFieldWrapper ajoute déjà un padding vertical (8px mobile),
// donc on n'ajoute pas de SizedBox supplémentaire ici pour éviter un double espacement.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildField(config: config, label: 'Nom', controller: _lastNameController),
_buildField(config: config, label: 'Prénom', controller: _firstNameController),
_buildField(config: config, label: 'Téléphone', controller: _phoneController),
_buildField(config: config, label: 'Email', controller: _emailController),
// Adresse complète en un seul bloc multiligne
FormFieldWrapper(
config: config,
label: 'Adresse',
value: "${_addressController.text}\n${_postalCodeController.text} ${_cityController.text}".trim(),
maxLines: 3,
),
],
);
}
// Mode Editable Mobile : Layout standard avec champs séparés
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Nom
_buildField(
config: config,
label: 'Nom',
controller: _lastNameController,
hint: 'Votre nom de famille',
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
),
const SizedBox(height: 12),
// Prénom
_buildField(
config: config,
label: 'Prénom',
controller: _firstNameController,
hint: 'Votre prénom',
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
),
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,
inputFormatters: frenchPhoneInputFormatters,
focusOrder: _focusPhone,
fieldValidator: _validateFrenchPhoneIfSecondParentNeeded,
),
const SizedBox(height: 12),
// Email
_buildField(
config: config,
label: 'Email',
controller: _emailController,
hint: 'Votre adresse e-mail',
keyboardType: TextInputType.emailAddress,
enabled: _fieldsEnabled,
focusOrder: _focusEmail,
fieldValidator: _validateEmailIfSecondParentNeeded,
inputFormatters: const [EmailMaxLengthFormatter()],
focusNode: _emailFocus,
formFieldKey: _emailFormKey,
),
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,
focusOrder: _focusAddress,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
const SizedBox(height: 12),
// Code Postal
_buildField(
config: config,
label: 'Code Postal',
controller: _postalCodeController,
hint: '5 chiffres',
keyboardType: TextInputType.number,
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusPostal,
fieldValidator: _validatePostalCodeField,
inputFormatters: kFrenchPostalCodeInputFormatters,
),
const SizedBox(height: 12),
// Ville
_buildField(
config: config,
label: 'Ville',
controller: _cityController,
hint: 'Votre ville',
enabled: _fieldsEnabled && !_sameAddress,
focusOrder: _focusCity,
focusNode: _cityFocus,
fieldValidator: widget.showSameAddressCheckbox
? _validateAddressLineIfManualEntry
: null,
),
],
);
}
/// Construit un champ individuel (éditable ou readonly)
Widget _buildField({
required DisplayConfig config,
required String label,
required TextEditingController controller,
String? hint,
TextInputType? keyboardType,
bool enabled = true,
List<TextInputFormatter>? inputFormatters,
double? focusOrder,
FocusNode? focusNode,
GlobalKey<FormFieldState<String>>? formFieldKey,
String? Function(String?)? fieldValidator,
}) {
if (config.isReadonly) {
// Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage)
final displayValue = label == 'Téléphone' && controller.text.trim().isNotEmpty
? formatPhoneForDisplay(controller.text)
: controller.text;
return FormFieldWrapper(
config: config,
label: label,
value: displayValue,
);
} else {
final effectiveKeyboardType = keyboardType ?? TextInputType.text;
final isEmail = effectiveKeyboardType == TextInputType.emailAddress;
Widget field = CustomAppTextField(
formFieldKey: formFieldKey,
controller: controller,
focusNode: focusNode,
labelText: label,
hintText: hint ?? label,
style: CustomAppTextFieldStyle.beige,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0,
keyboardType: effectiveKeyboardType,
autocorrect: !isEmail,
enableSuggestions: !isEmail,
autofillHints: isEmail ? const [AutofillHints.email] : null,
textInputAction: isEmail ? TextInputAction.next : null,
enabled: enabled,
inputFormatters: inputFormatters,
validator: fieldValidator,
isRequired: fieldValidator == null,
);
if (focusOrder != null) {
field = FocusTraversalOrder(
order: NumericFocusOrder(focusOrder),
child: field,
);
}
return field;
}
}
static final _phoneInputFormatters = [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
];
/// Retourne l'asset de carte vertical correspondant à la couleur
String _getVerticalCardAsset() {
switch (widget.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;
}
}
}