From b4abb7d6de1ff950b54a089e03dc48d6fe83a4a7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 13:01:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(#129):=20peaufiner=20le=20wizard=20famille?= =?UTF-8?q?=20(co-parent,=20hauteurs,=20=C3=A0=20na=C3=AEtre).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../common/validation_detail_section.dart | 52 +++- .../widgets/admin/parent_dossier_wizard.dart | 275 +++++++++++++----- 2 files changed, 239 insertions(+), 88 deletions(-) diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index faf9f6e..80f5a42 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -35,19 +35,23 @@ class ValidationFormMetrics { ); static double get sectionTitleBlockHeight => - sectionTitleFontSize + sectionTitleGapBelow; + sectionTitleFontSize * 1.25 + sectionTitleGapBelow; + /// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police). static double get labeledRowHeight => - fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow; + fieldLabelFontSize * 1.25 + + fieldLabelGapBelow + + fieldHeight + + rowGapBelow; - /// Corps modale AM : padding wizard + titre + [rows] lignes + nav. + /// Corps modale AM / famille : padding wizard + titre + [rows] lignes + nav. static double shellBodyHeightForRows(int rows) => 20 * 2 + // padding wizard 4 + // espace haut sectionTitleBlockHeight + rows * labeledRowHeight + 24 + // avant nav - 44; // boutons + 48; // boutons (+ marge anti-overflow) } /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. @@ -134,6 +138,7 @@ class ValidationFormGrid extends StatelessWidget { label: f.label, field: f.field, expand: expandVertically, + labelTrailing: f.labelTrailing, ), ) .toList(); @@ -262,28 +267,37 @@ class ValidationLabeledField extends StatelessWidget { final String label; final Widget field; final bool expand; + /// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »). + final Widget? labelTrailing; const ValidationLabeledField({ super.key, required this.label, required this.field, this.expand = false, + this.labelTrailing, }); @override Widget build(BuildContext context) { + final labelStyle = TextStyle( + fontSize: ValidationFormMetrics.fieldLabelFontSize, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, children: [ - Text( - label, - style: TextStyle( - fontSize: ValidationFormMetrics.fieldLabelFontSize, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, + if (labelTrailing == null) + Text(label, style: labelStyle) + else + Row( + children: [ + Expanded(child: Text(label, style: labelStyle)), + labelTrailing!, + ], ), - ), SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow), if (expand) Expanded(child: field) else field, ], @@ -299,6 +313,7 @@ class ValidationEditableField extends StatelessWidget { final String? hintText; final int maxLines; final bool compact; + final bool enabled; const ValidationEditableField({ super.key, @@ -308,6 +323,7 @@ class ValidationEditableField extends StatelessWidget { this.hintText, this.maxLines = 1, this.compact = false, + this.enabled = true, }); static const double _compactFieldHeight = 34; @@ -339,6 +355,7 @@ class ValidationEditableField extends StatelessWidget { if (maxLines > 1) { return TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: maxLines, @@ -350,6 +367,7 @@ class ValidationEditableField extends StatelessWidget { return _validationFieldFillHeight( TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: 1, @@ -365,6 +383,7 @@ class ValidationEditableField extends StatelessWidget { decoration: _compactDecoration(), child: TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: 1, @@ -381,13 +400,15 @@ class ValidationEditableField extends StatelessWidget { } } -/// Étire le champ si le parent impose une hauteur (grille [expandVertically]). +/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo. Widget _validationFieldFillHeight(Widget field) { return LayoutBuilder( builder: (context, c) { - if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field; + final h = (c.hasBoundedHeight && c.maxHeight.isFinite) + ? c.maxHeight + : ValidationFormMetrics.fieldHeight; return SizedBox( - height: c.maxHeight, + height: h, width: double.infinity, child: field, ); @@ -481,12 +502,14 @@ class ValidationPostalCodeField extends StatefulWidget { final TextEditingController controller; final String? hintText; final bool allowEmpty; + final bool enabled; const ValidationPostalCodeField({ super.key, required this.controller, this.hintText, this.allowEmpty = false, + this.enabled = true, }); @override @@ -536,6 +559,7 @@ class _ValidationPostalCodeFieldState extends State { key: _fieldKey, controller: widget.controller, focusNode: _focusNode, + enabled: widget.enabled, keyboardType: TextInputType.number, textInputAction: TextInputAction.next, textAlignVertical: TextAlignVertical.center, diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart index 051b183..a70fc7f 100644 --- a/frontend/lib/widgets/admin/parent_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -30,6 +30,7 @@ class _CreateChild { final TextEditingController nomCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController(); String? genre; + bool isUnborn = false; Uint8List? photoBytes; String? photoFilename; @@ -92,9 +93,9 @@ class ParentDossierWizard extends StatefulWidget { bool get isCreate => mode == ParentDossierWizardMode.create; - /// Hauteur corps modale famille — dérivée de [ValidationFormMetrics] (4 lignes). + /// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch. static double get shellBodyHeight => - ValidationFormMetrics.shellBodyHeightForRows(4); + ValidationFormMetrics.shellBodyHeightForRows(4) + 20; @override State createState() => _ParentDossierWizardState(); @@ -319,60 +320,122 @@ class _ParentDossierWizardState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Ajouter un co-parent'), - value: _hasCoParent, - onChanged: (v) => setState(() => _hasCoParent = v), - ), - if (!_hasCoParent) - const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text( - 'Un seul parent pour ce dossier.', - style: TextStyle(color: Colors.black87), - ), - ) - else ...[ - const SizedBox(height: 8), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - IdentityBlock.editable( - title: 'Deuxième parent', - nomController: _p2NomCtrl, - prenomController: _p2PrenomCtrl, - telephoneController: _p2TelCtrl, - emailController: _p2EmailCtrl, - adresseController: _p2AdresseCtrl, - codePostalController: _p2CpCtrl, - villeController: _p2VilleCtrl, - ), - const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Même adresse que le parent principal'), - value: _sameAddress, - onChanged: (v) => setState(() { - _sameAddress = v; - if (v) { - _p2AdresseCtrl.text = _p1AdresseCtrl.text; - _p2CpCtrl.text = _p1CpCtrl.text; - _p2VilleCtrl.text = _p1VilleCtrl.text; - } - }), - ), - ], + Row( + children: [ + const Text( + 'Deuxième parent', + style: TextStyle( + fontSize: ValidationFormMetrics.sectionTitleFontSize, + fontWeight: FontWeight.w600, + color: Colors.black87, ), ), + const Spacer(), + const Text( + 'Ajouter un co-parent', + style: TextStyle(fontSize: 14, color: Colors.black87), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _hasCoParent, + onChanged: (v) => setState(() => _hasCoParent = v), + ), + ), + ], + ), + const SizedBox(height: 8), + if (!_hasCoParent) + const Text( + 'Un seul parent pour ce dossier.', + style: TextStyle(color: Colors.black87), + ) + else + ValidationFormGrid( + compact: true, + rowLayout: IdentityBlock.rowLayout, + rowFlex: IdentityBlock.rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField( + controller: _p2NomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField( + controller: _p2PrenomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationPhoneField(controller: _p2TelCtrl), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationEmailField(controller: _p2EmailCtrl), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + labelTrailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Même adresse', + style: TextStyle( + fontSize: ValidationFormMetrics.fieldLabelFontSize, + color: Colors.black87, + ), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _sameAddress, + onChanged: (v) => setState(() { + _sameAddress = v; + if (v) _copyP1AddressToP2(); + }), + ), + ), + ], + ), + field: ValidationEditableField( + controller: _p2AdresseCtrl, + enabled: !_sameAddress, + ), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationPostalCodeField( + controller: _p2CpCtrl, + enabled: !_sameAddress, + ), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationEditableField( + controller: _p2VilleCtrl, + enabled: !_sameAddress, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ], ), - ], ], ); } + void _copyP1AddressToP2() { + _p2AdresseCtrl.text = _p1AdresseCtrl.text; + _p2CpCtrl.text = _p1CpCtrl.text; + _p2VilleCtrl.text = _p1VilleCtrl.text; + } + Widget _buildParent2Step() { if (_dossier.parents.length < 2) { return const Padding( @@ -643,11 +706,12 @@ class _ParentDossierWizardState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 40, 8), + padding: const EdgeInsets.fromLTRB(12, 12, 14, 8), child: ValidationLabeledField( label: 'Prénom', field: ValidationEditableField( controller: child.prenomCtrl, + hintText: child.isUnborn ? 'Facultatif' : null, inputFormatters: const [PersonNameInputFormatter()], ), ), @@ -715,7 +779,36 @@ class _ParentDossierWizardState extends State { ), const SizedBox(height: 12), ValidationLabeledField( - label: 'Date de naissance', + label: child.isUnborn + ? 'Date prévisionnelle' + : 'Date de naissance', + labelTrailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'À naître', + style: TextStyle( + fontSize: ValidationFormMetrics + .fieldLabelFontSize, + color: Colors.black87, + ), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + value: child.isUnborn, + onChanged: (v) => setState(() { + child.isUnborn = v; + if (!v && child.genre == 'Autre') { + child.genre = null; + } + }), + ), + ), + ], + ), field: ValidationEditableField( controller: child.dateCtrl, hintText: 'jj / mm / aaaa', @@ -766,20 +859,28 @@ class _ParentDossierWizardState extends State { Widget _genreDropdown(int index) { final child = _children[index]; + final items = >[ + const DropdownMenuItem(value: 'H', child: Text('Garçon')), + const DropdownMenuItem(value: 'F', child: Text('Fille')), + if (child.isUnborn) + const DropdownMenuItem(value: 'Autre', child: Text('Inconnu')), + ]; + final value = child.genre != null && + items.any((e) => e.value == child.genre) + ? child.genre + : null; return SizedBox( height: ValidationFormMetrics.fieldHeight, child: DropdownButtonFormField( - value: child.genre, + key: ValueKey('genre-$index-${child.isUnborn}'), + value: value, isExpanded: true, hint: Text( 'Sélectionner', style: TextStyle(color: Colors.grey.shade600, fontSize: 14), ), decoration: ValidationFieldDecoration.input(), - items: const [ - DropdownMenuItem(value: 'H', child: Text('Garçon')), - DropdownMenuItem(value: 'F', child: Text('Fille')), - ], + items: items, onChanged: (v) => setState(() => child.genre = v), ), ); @@ -1118,6 +1219,10 @@ class _ParentDossierWizardState extends State { normalizeEmailText(_p1EmailCtrl.text)) { return 'L’email du co-parent doit être différent de celui du parent principal.'; } + if (_sameAddress) { + _copyP1AddressToP2(); + return null; + } if (_p2AdresseCtrl.text.trim().isEmpty) { return 'L’adresse du co-parent est requise.'; } @@ -1134,14 +1239,27 @@ class _ParentDossierWizardState extends State { for (var i = 0; i < _children.length; i++) { final c = _children[i]; final label = 'Enfant ${i + 1}'; - if (c.prenomCtrl.text.trim().length < 2) { - return '$label : le prénom doit contenir au moins 2 caractères.'; - } - if (parseFrDateToIso(c.dateCtrl.text) == null) { - return '$label : indiquez une date de naissance valide (jj/mm/aaaa).'; - } - if (c.genre != 'H' && c.genre != 'F') { - return '$label : indiquez le genre (Garçon ou Fille).'; + final prenom = c.prenomCtrl.text.trim(); + if (c.isUnborn) { + if (prenom.isNotEmpty && prenom.length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + if (parseFrDateToIso(c.dateCtrl.text) == null) { + return '$label : indiquez une date prévisionnelle valide (jj/mm/aaaa).'; + } + if (c.genre != 'H' && c.genre != 'F' && c.genre != 'Autre') { + return '$label : indiquez le genre (Garçon, Fille ou Inconnu).'; + } + } else { + if (prenom.length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + if (parseFrDateToIso(c.dateCtrl.text) == null) { + return '$label : indiquez une date de naissance valide (jj/mm/aaaa).'; + } + if (c.genre != 'H' && c.genre != 'F') { + return '$label : indiquez le genre (Garçon ou Fille).'; + } } } return null; @@ -1207,17 +1325,29 @@ class _ParentDossierWizardState extends State { final nom = nomRaw.isNotEmpty ? formatPersonNameCase(nomRaw) : formatPersonNameCase(_p1NomCtrl.text); - final birthIso = parseFrDateToIso(c.dateCtrl.text); + final dateIso = parseFrDateToIso(c.dateCtrl.text); final map = { - 'prenom': prenom, - 'nom': nom, - 'date_naissance': birthIso, 'genre': c.genre, 'consent_photo': true, 'grossesse_multiple': false, }; + if (prenom.length >= 2) { + map['prenom'] = prenom; + } + if (nom.length >= 2) { + map['nom'] = nom; + } + + if (c.isUnborn) { + if (dateIso != null) { + map['date_previsionnelle_naissance'] = dateIso; + } + } else if (dateIso != null) { + map['date_naissance'] = dateIso; + } + final bytes = c.photoBytes; if (bytes != null && bytes.isNotEmpty) { final mime = _imageMimeForBytes(bytes); @@ -1243,16 +1373,13 @@ class _ParentDossierWizardState extends State { }; if (_hasCoParent) { - final sameAddr = - _p2AdresseCtrl.text.trim() == _p1AdresseCtrl.text.trim() && - _p2CpCtrl.text.trim() == _p1CpCtrl.text.trim() && - _p2VilleCtrl.text.trim() == _p1VilleCtrl.text.trim(); + if (_sameAddress) _copyP1AddressToP2(); body['co_parent_email'] = normalizeEmailText(_p2EmailCtrl.text); body['co_parent_prenom'] = formatPersonNameCase(_p2PrenomCtrl.text); body['co_parent_nom'] = formatPersonNameCase(_p2NomCtrl.text); body['co_parent_telephone'] = normalizePhone(_p2TelCtrl.text); - body['co_parent_meme_adresse'] = sameAddr; - if (!sameAddr) { + body['co_parent_meme_adresse'] = _sameAddress; + if (!_sameAddress) { body['co_parent_adresse'] = _p2AdresseCtrl.text.trim(); body['co_parent_code_postal'] = _p2CpCtrl.text.trim(); body['co_parent_ville'] = formatPersonNameCase(_p2VilleCtrl.text);