fix(#129): peaufiner le wizard famille (co-parent, hauteurs, à naître).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 13:01:54 +02:00
co-authored by Cursor
parent cb5c1a5518
commit b4abb7d6de
2 changed files with 239 additions and 88 deletions
@@ -35,19 +35,23 @@ class ValidationFormMetrics {
); );
static double get sectionTitleBlockHeight => static double get sectionTitleBlockHeight =>
sectionTitleFontSize + sectionTitleGapBelow; sectionTitleFontSize * 1.25 + sectionTitleGapBelow;
/// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police).
static double get labeledRowHeight => 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) => static double shellBodyHeightForRows(int rows) =>
20 * 2 + // padding wizard 20 * 2 + // padding wizard
4 + // espace haut 4 + // espace haut
sectionTitleBlockHeight + sectionTitleBlockHeight +
rows * labeledRowHeight + rows * labeledRowHeight +
24 + // avant nav 24 + // avant nav
44; // boutons 48; // boutons (+ marge anti-overflow)
} }
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. /// 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, label: f.label,
field: f.field, field: f.field,
expand: expandVertically, expand: expandVertically,
labelTrailing: f.labelTrailing,
), ),
) )
.toList(); .toList();
@@ -262,28 +267,37 @@ class ValidationLabeledField extends StatelessWidget {
final String label; final String label;
final Widget field; final Widget field;
final bool expand; final bool expand;
/// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »).
final Widget? labelTrailing;
const ValidationLabeledField({ const ValidationLabeledField({
super.key, super.key,
required this.label, required this.label,
required this.field, required this.field,
this.expand = false, this.expand = false,
this.labelTrailing,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final labelStyle = TextStyle(
fontSize: ValidationFormMetrics.fieldLabelFontSize,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
children: [ children: [
Text( if (labelTrailing == null)
label, Text(label, style: labelStyle)
style: TextStyle( else
fontSize: ValidationFormMetrics.fieldLabelFontSize, Row(
fontWeight: FontWeight.w500, children: [
color: Colors.grey.shade700, Expanded(child: Text(label, style: labelStyle)),
labelTrailing!,
],
), ),
),
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow), SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
if (expand) Expanded(child: field) else field, if (expand) Expanded(child: field) else field,
], ],
@@ -299,6 +313,7 @@ class ValidationEditableField extends StatelessWidget {
final String? hintText; final String? hintText;
final int maxLines; final int maxLines;
final bool compact; final bool compact;
final bool enabled;
const ValidationEditableField({ const ValidationEditableField({
super.key, super.key,
@@ -308,6 +323,7 @@ class ValidationEditableField extends StatelessWidget {
this.hintText, this.hintText,
this.maxLines = 1, this.maxLines = 1,
this.compact = false, this.compact = false,
this.enabled = true,
}); });
static const double _compactFieldHeight = 34; static const double _compactFieldHeight = 34;
@@ -339,6 +355,7 @@ class ValidationEditableField extends StatelessWidget {
if (maxLines > 1) { if (maxLines > 1) {
return TextField( return TextField(
controller: controller, controller: controller,
enabled: enabled,
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
maxLines: maxLines, maxLines: maxLines,
@@ -350,6 +367,7 @@ class ValidationEditableField extends StatelessWidget {
return _validationFieldFillHeight( return _validationFieldFillHeight(
TextField( TextField(
controller: controller, controller: controller,
enabled: enabled,
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
maxLines: 1, maxLines: 1,
@@ -365,6 +383,7 @@ class ValidationEditableField extends StatelessWidget {
decoration: _compactDecoration(), decoration: _compactDecoration(),
child: TextField( child: TextField(
controller: controller, controller: controller,
enabled: enabled,
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
maxLines: 1, 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) { Widget _validationFieldFillHeight(Widget field) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, c) { builder: (context, c) {
if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field; final h = (c.hasBoundedHeight && c.maxHeight.isFinite)
? c.maxHeight
: ValidationFormMetrics.fieldHeight;
return SizedBox( return SizedBox(
height: c.maxHeight, height: h,
width: double.infinity, width: double.infinity,
child: field, child: field,
); );
@@ -481,12 +502,14 @@ class ValidationPostalCodeField extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
final String? hintText; final String? hintText;
final bool allowEmpty; final bool allowEmpty;
final bool enabled;
const ValidationPostalCodeField({ const ValidationPostalCodeField({
super.key, super.key,
required this.controller, required this.controller,
this.hintText, this.hintText,
this.allowEmpty = false, this.allowEmpty = false,
this.enabled = true,
}); });
@override @override
@@ -536,6 +559,7 @@ class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
key: _fieldKey, key: _fieldKey,
controller: widget.controller, controller: widget.controller,
focusNode: _focusNode, focusNode: _focusNode,
enabled: widget.enabled,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center, textAlignVertical: TextAlignVertical.center,
@@ -30,6 +30,7 @@ class _CreateChild {
final TextEditingController nomCtrl = TextEditingController(); final TextEditingController nomCtrl = TextEditingController();
final TextEditingController dateCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController();
String? genre; String? genre;
bool isUnborn = false;
Uint8List? photoBytes; Uint8List? photoBytes;
String? photoFilename; String? photoFilename;
@@ -92,9 +93,9 @@ class ParentDossierWizard extends StatefulWidget {
bool get isCreate => mode == ParentDossierWizardMode.create; 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 => static double get shellBodyHeight =>
ValidationFormMetrics.shellBodyHeightForRows(4); ValidationFormMetrics.shellBodyHeightForRows(4) + 20;
@override @override
State<ParentDossierWizard> createState() => _ParentDossierWizardState(); State<ParentDossierWizard> createState() => _ParentDossierWizardState();
@@ -319,60 +320,122 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SwitchListTile( Row(
contentPadding: EdgeInsets.zero, children: [
title: const Text('Ajouter un co-parent'), const Text(
value: _hasCoParent, 'Deuxième parent',
onChanged: (v) => setState(() => _hasCoParent = v), style: TextStyle(
), fontSize: ValidationFormMetrics.sectionTitleFontSize,
if (!_hasCoParent) fontWeight: FontWeight.w600,
const Padding( color: Colors.black87,
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;
}
}),
),
],
), ),
), ),
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() { Widget _buildParent2Step() {
if (_dossier.parents.length < 2) { if (_dossier.parents.length < 2) {
return const Padding( return const Padding(
@@ -643,11 +706,12 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 40, 8), padding: const EdgeInsets.fromLTRB(12, 12, 14, 8),
child: ValidationLabeledField( child: ValidationLabeledField(
label: 'Prénom', label: 'Prénom',
field: ValidationEditableField( field: ValidationEditableField(
controller: child.prenomCtrl, controller: child.prenomCtrl,
hintText: child.isUnborn ? 'Facultatif' : null,
inputFormatters: const [PersonNameInputFormatter()], inputFormatters: const [PersonNameInputFormatter()],
), ),
), ),
@@ -715,7 +779,36 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
ValidationLabeledField( 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( field: ValidationEditableField(
controller: child.dateCtrl, controller: child.dateCtrl,
hintText: 'jj / mm / aaaa', hintText: 'jj / mm / aaaa',
@@ -766,20 +859,28 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
Widget _genreDropdown(int index) { Widget _genreDropdown(int index) {
final child = _children[index]; final child = _children[index];
final items = <DropdownMenuItem<String>>[
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( return SizedBox(
height: ValidationFormMetrics.fieldHeight, height: ValidationFormMetrics.fieldHeight,
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: child.genre, key: ValueKey('genre-$index-${child.isUnborn}'),
value: value,
isExpanded: true, isExpanded: true,
hint: Text( hint: Text(
'Sélectionner', 'Sélectionner',
style: TextStyle(color: Colors.grey.shade600, fontSize: 14), style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
), ),
decoration: ValidationFieldDecoration.input(), decoration: ValidationFieldDecoration.input(),
items: const [ items: items,
DropdownMenuItem(value: 'H', child: Text('Garçon')),
DropdownMenuItem(value: 'F', child: Text('Fille')),
],
onChanged: (v) => setState(() => child.genre = v), onChanged: (v) => setState(() => child.genre = v),
), ),
); );
@@ -1118,6 +1219,10 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
normalizeEmailText(_p1EmailCtrl.text)) { normalizeEmailText(_p1EmailCtrl.text)) {
return 'Lemail du co-parent doit être différent de celui du parent principal.'; return 'Lemail du co-parent doit être différent de celui du parent principal.';
} }
if (_sameAddress) {
_copyP1AddressToP2();
return null;
}
if (_p2AdresseCtrl.text.trim().isEmpty) { if (_p2AdresseCtrl.text.trim().isEmpty) {
return 'Ladresse du co-parent est requise.'; return 'Ladresse du co-parent est requise.';
} }
@@ -1134,14 +1239,27 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
for (var i = 0; i < _children.length; i++) { for (var i = 0; i < _children.length; i++) {
final c = _children[i]; final c = _children[i];
final label = 'Enfant ${i + 1}'; final label = 'Enfant ${i + 1}';
if (c.prenomCtrl.text.trim().length < 2) { final prenom = c.prenomCtrl.text.trim();
return '$label : le prénom doit contenir au moins 2 caractères.'; if (c.isUnborn) {
} if (prenom.isNotEmpty && prenom.length < 2) {
if (parseFrDateToIso(c.dateCtrl.text) == null) { return '$label : le prénom doit contenir au moins 2 caractères.';
return '$label : indiquez une date de naissance valide (jj/mm/aaaa).'; }
} if (parseFrDateToIso(c.dateCtrl.text) == null) {
if (c.genre != 'H' && c.genre != 'F') { return '$label : indiquez une date prévisionnelle valide (jj/mm/aaaa).';
return '$label : indiquez le genre (Garçon ou Fille).'; }
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; return null;
@@ -1207,17 +1325,29 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
final nom = nomRaw.isNotEmpty final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw) ? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text); : formatPersonNameCase(_p1NomCtrl.text);
final birthIso = parseFrDateToIso(c.dateCtrl.text); final dateIso = parseFrDateToIso(c.dateCtrl.text);
final map = <String, dynamic>{ final map = <String, dynamic>{
'prenom': prenom,
'nom': nom,
'date_naissance': birthIso,
'genre': c.genre, 'genre': c.genre,
'consent_photo': true, 'consent_photo': true,
'grossesse_multiple': false, '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; final bytes = c.photoBytes;
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
final mime = _imageMimeForBytes(bytes); final mime = _imageMimeForBytes(bytes);
@@ -1243,16 +1373,13 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
}; };
if (_hasCoParent) { if (_hasCoParent) {
final sameAddr = if (_sameAddress) _copyP1AddressToP2();
_p2AdresseCtrl.text.trim() == _p1AdresseCtrl.text.trim() &&
_p2CpCtrl.text.trim() == _p1CpCtrl.text.trim() &&
_p2VilleCtrl.text.trim() == _p1VilleCtrl.text.trim();
body['co_parent_email'] = normalizeEmailText(_p2EmailCtrl.text); body['co_parent_email'] = normalizeEmailText(_p2EmailCtrl.text);
body['co_parent_prenom'] = formatPersonNameCase(_p2PrenomCtrl.text); body['co_parent_prenom'] = formatPersonNameCase(_p2PrenomCtrl.text);
body['co_parent_nom'] = formatPersonNameCase(_p2NomCtrl.text); body['co_parent_nom'] = formatPersonNameCase(_p2NomCtrl.text);
body['co_parent_telephone'] = normalizePhone(_p2TelCtrl.text); body['co_parent_telephone'] = normalizePhone(_p2TelCtrl.text);
body['co_parent_meme_adresse'] = sameAddr; body['co_parent_meme_adresse'] = _sameAddress;
if (!sameAddr) { if (!_sameAddress) {
body['co_parent_adresse'] = _p2AdresseCtrl.text.trim(); body['co_parent_adresse'] = _p2AdresseCtrl.text.trim();
body['co_parent_code_postal'] = _p2CpCtrl.text.trim(); body['co_parent_code_postal'] = _p2CpCtrl.text.trim();
body['co_parent_ville'] = formatPersonNameCase(_p2VilleCtrl.text); body['co_parent_ville'] = formatPersonNameCase(_p2VilleCtrl.text);