import 'dart:convert'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; enum ParentDossierWizardMode { review, create, edit } /// Enfant en cours de saisie (création / édition). Contrôleurs propres, à disposer. class _CreateChild { /// Id existant en base (mode edit) — null = nouvel enfant → POST. String? existingChildId; String? existingPhotoUrl; /// Statut d’origine (hors à naître) pour ne pas écraser garde/scolarise. String? existingStatus; final TextEditingController prenomCtrl = TextEditingController(); final TextEditingController nomCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController(); String? genre; bool isUnborn = false; Uint8List? photoBytes; String? photoFilename; bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty; void dispose() { prenomCtrl.dispose(); nomCtrl.dispose(); dateCtrl.dispose(); } } /// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135). class ParentDossierWizard extends StatefulWidget { final ParentDossierWizardMode mode; final DossierFamille? dossier; final VoidCallback onClose; final VoidCallback onSuccess; final void Function(int step, int total)? onStepChanged; const ParentDossierWizard._({ super.key, required this.mode, this.dossier, required this.onClose, required this.onSuccess, this.onStepChanged, }); factory ParentDossierWizard.review({ Key? key, required DossierFamille dossier, required VoidCallback onClose, required VoidCallback onSuccess, void Function(int step, int total)? onStepChanged, }) { return ParentDossierWizard._( key: key, mode: ParentDossierWizardMode.review, dossier: dossier, onClose: onClose, onSuccess: onSuccess, onStepChanged: onStepChanged, ); } factory ParentDossierWizard.create({ Key? key, required VoidCallback onClose, required VoidCallback onSuccess, void Function(int step, int total)? onStepChanged, }) { return ParentDossierWizard._( key: key, mode: ParentDossierWizardMode.create, onClose: onClose, onSuccess: onSuccess, onStepChanged: onStepChanged, ); } factory ParentDossierWizard.edit({ Key? key, required DossierFamille dossier, required VoidCallback onClose, required VoidCallback onSuccess, void Function(int step, int total)? onStepChanged, }) { return ParentDossierWizard._( key: key, mode: ParentDossierWizardMode.edit, dossier: dossier, onClose: onClose, onSuccess: onSuccess, onStepChanged: onStepChanged, ); } bool get isCreate => mode == ParentDossierWizardMode.create; bool get isEdit => mode == ParentDossierWizardMode.edit; /// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch. static double get shellBodyHeight => ValidationFormMetrics.shellBodyHeightForRows(4) + 20; @override State createState() => _ParentDossierWizardState(); } class _ParentDossierWizardState extends State { int _step = 0; bool _showRefusForm = false; bool _submitting = false; final ScrollController _enfantsScrollController = ScrollController(); /// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn). bool _enfantsIsScrollable = false; bool _enfantsFadeLeft = false; bool _enfantsFadeRight = false; /// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3). static const double _enfantsFadeExtent = 0.05; static const int _stepCount = 4; // --- Mode création : parent principal --- late final TextEditingController _p1NomCtrl; late final TextEditingController _p1PrenomCtrl; late final TextEditingController _p1TelCtrl; late final TextEditingController _p1EmailCtrl; late final TextEditingController _p1AdresseCtrl; late final TextEditingController _p1CpCtrl; late final TextEditingController _p1VilleCtrl; // --- Mode création : co-parent (optionnel) --- bool _hasCoParent = false; bool _sameAddress = false; late final TextEditingController _p2NomCtrl; late final TextEditingController _p2PrenomCtrl; late final TextEditingController _p2TelCtrl; late final TextEditingController _p2EmailCtrl; late final TextEditingController _p2AdresseCtrl; late final TextEditingController _p2CpCtrl; late final TextEditingController _p2VilleCtrl; // --- Mode création : enfants + présentation --- final List<_CreateChild> _children = []; late final TextEditingController _presentationCtrl; /// Nb de parents déjà en base au moment de l’ouverture (mode edit). int _initialParentCount = 0; /// Enfants existants retirés en edit → DELETE au save. final List _removedEnfantIds = []; bool get _isCreate => widget.isCreate; bool get _isEdit => widget.isEdit; bool get _isEditable => _isCreate || _isEdit; DossierFamille get _dossier => widget.dossier!; bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente; String? get _firstParentId { if (_isCreate) return null; return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null; } /// Co-parent déjà présent au chargement (edit) — pas un ajout via POST. bool get _hadExistingCoParent => _isEdit && _initialParentCount >= 2; static String _v(String? s) => (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; /// Date de naissance en jour/mois/année (dd/MM/yyyy). static String _formatBirthDate(String? s) => formatIsoDateFr(s, ifEmpty: 'Non défini'); static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); @override void initState() { super.initState(); _enfantsScrollController.addListener(_syncEnfantsScrollFades); _p1NomCtrl = TextEditingController(); _p1PrenomCtrl = TextEditingController(); _p1TelCtrl = TextEditingController(); _p1EmailCtrl = TextEditingController(); _p1AdresseCtrl = TextEditingController(); _p1CpCtrl = TextEditingController(); _p1VilleCtrl = TextEditingController(); _p2NomCtrl = TextEditingController(); _p2PrenomCtrl = TextEditingController(); _p2TelCtrl = TextEditingController(); _p2EmailCtrl = TextEditingController(); _p2AdresseCtrl = TextEditingController(); _p2CpCtrl = TextEditingController(); _p2VilleCtrl = TextEditingController(); _presentationCtrl = TextEditingController(); if (_isCreate) { _children.add(_CreateChild()); } else if (_isEdit) { _prefillFromDossier(); } WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); } void _prefillFromDossier() { final parents = _dossier.parents; _initialParentCount = parents.length; if (parents.isNotEmpty) { final p1 = parents.first; _p1NomCtrl.text = (p1.nom ?? '').trim(); _p1PrenomCtrl.text = (p1.prenom ?? '').trim(); _p1TelCtrl.text = (p1.telephone ?? '').trim(); _p1EmailCtrl.text = (p1.email).trim(); _p1AdresseCtrl.text = (p1.adresse ?? '').trim(); _p1CpCtrl.text = (p1.codePostal ?? '').trim(); _p1VilleCtrl.text = (p1.ville ?? '').trim(); if (parents.length >= 2) { _hasCoParent = true; final p2 = parents[1]; _p2NomCtrl.text = (p2.nom ?? '').trim(); _p2PrenomCtrl.text = (p2.prenom ?? '').trim(); _p2TelCtrl.text = (p2.telephone ?? '').trim(); _p2EmailCtrl.text = (p2.email).trim(); _p2AdresseCtrl.text = (p2.adresse ?? '').trim(); _p2CpCtrl.text = (p2.codePostal ?? '').trim(); _p2VilleCtrl.text = (p2.ville ?? '').trim(); } else { _hasCoParent = false; } } _prefillChildrenFromDossier(); } void _prefillChildrenFromDossier() { for (final c in _children) { c.dispose(); } _children.clear(); _removedEnfantIds.clear(); final enfants = _dossier.enfants; if (enfants.isEmpty) { _children.add(_CreateChild()); return; } for (final e in enfants) { final status = (e.status ?? '').trim().toLowerCase(); final id = e.id.trim(); final child = _CreateChild() ..existingChildId = id.isEmpty ? null : id ..existingPhotoUrl = e.photoUrl ..existingStatus = status.isEmpty ? null : status ..isUnborn = status == 'a_naitre'; child.prenomCtrl.text = (e.firstName ?? '').trim(); child.nomCtrl.text = (e.lastName ?? '').trim(); final g = (e.gender ?? '').trim(); final gUp = g.toUpperCase(); if (gUp == 'H' || gUp == 'F') { child.genre = gUp; } else if (g == 'Autre' || child.isUnborn) { child.genre = child.isUnborn ? 'Autre' : g; } final dateSrc = child.isUnborn ? e.dueDate : e.birthDate; child.dateCtrl.text = formatIsoDateFrInput(dateSrc); _children.add(child); } } @override void dispose() { _enfantsScrollController.removeListener(_syncEnfantsScrollFades); _enfantsScrollController.dispose(); _p1NomCtrl.dispose(); _p1PrenomCtrl.dispose(); _p1TelCtrl.dispose(); _p1EmailCtrl.dispose(); _p1AdresseCtrl.dispose(); _p1CpCtrl.dispose(); _p1VilleCtrl.dispose(); _p2NomCtrl.dispose(); _p2PrenomCtrl.dispose(); _p2TelCtrl.dispose(); _p2EmailCtrl.dispose(); _p2AdresseCtrl.dispose(); _p2CpCtrl.dispose(); _p2VilleCtrl.dispose(); for (final c in _children) { c.dispose(); } _presentationCtrl.dispose(); super.dispose(); } void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); void _showError(String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), backgroundColor: Colors.red.shade700), ); } void _syncEnfantsScrollFades() { if (!mounted) return; if (!_enfantsScrollController.hasClients) { if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) { setState(() { _enfantsIsScrollable = false; _enfantsFadeLeft = false; _enfantsFadeRight = false; }); } return; } final p = _enfantsScrollController.position; final scrollable = p.maxScrollExtent > 0; final left = scrollable && p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2); final right = scrollable && p.pixels < (p.maxScrollExtent - (p.viewportDimension * _enfantsFadeExtent / 2)); if (scrollable != _enfantsIsScrollable || left != _enfantsFadeLeft || right != _enfantsFadeRight) { setState(() { _enfantsIsScrollable = scrollable; _enfantsFadeLeft = left; _enfantsFadeRight = right; }); } } @override Widget build(BuildContext context) { if (_showRefusForm) { return _buildRefusPage(); } return Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 4), Expanded(child: _buildStepContent()), const SizedBox(height: 24), _buildNavigation(), ], ), ); } Widget _buildStepContent() { switch (_step) { case 0: return _buildStep0(); case 1: return _buildStep1(); case 2: return _buildEnfantsStep(); case 3: return _buildPresentationStep(); default: return const SizedBox(); } } Widget _buildStep0() { if (_isEditable) { return IdentityBlock.editable( title: 'Parent principal', nomController: _p1NomCtrl, prenomController: _p1PrenomCtrl, telephoneController: _p1TelCtrl, emailController: _p1EmailCtrl, adresseController: _p1AdresseCtrl, codePostalController: _p1CpCtrl, villeController: _p1VilleCtrl, ); } return IdentityBlock.readOnlyFromParentDossier( _dossier.parents.first, title: 'Parent principal', ); } Widget _buildStep1() { if (_isEditable) { return _buildCoParentStepEditable( allowToggle: !_hadExistingCoParent, ); } return _buildParent2Step(); } Widget _buildCoParentStepEditable({required bool allowToggle}) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ const Text( 'Deuxième parent', style: TextStyle( fontSize: ValidationFormMetrics.sectionTitleFontSize, fontWeight: FontWeight.w600, color: Colors.black87, ), ), if (allowToggle) ...[ 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( padding: EdgeInsets.symmetric(vertical: 12), child: Text('Un seul parent pour ce dossier.', style: TextStyle(color: Colors.black87)), ); } return IdentityBlock.readOnlyFromParentDossier( _dossier.parents[1], title: 'Deuxième parent', ); } static const double _idPhotoAspectRatio = 35 / 45; /// Liste horizontale scrollable (fondu bords) partagée par les cartes enfant /// en lecture seule (review) et éditables (create). Widget _buildEnfantsHorizontalList({ required int itemCount, required Widget Function(int index) itemBuilder, }) { return LayoutBuilder( builder: (context, constraints) { final cardHeight = constraints.maxHeight; // Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants). final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0); return NotificationListener( onNotification: (_) { _syncEnfantsScrollFades(); return false; }, child: ShaderMask( blendMode: BlendMode.dstIn, shaderCallback: (Rect bounds) { final stops = [ 0.0, _enfantsFadeExtent, 1.0 - _enfantsFadeExtent, 1.0, ]; if (!_enfantsIsScrollable) { return LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: const [ Colors.black, Colors.black, Colors.black, Colors.black, ], stops: stops, ).createShader(bounds); } final leftMask = _enfantsFadeLeft ? Colors.transparent : Colors.black; final rightMask = _enfantsFadeRight ? Colors.transparent : Colors.black; return LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ leftMask, Colors.black, Colors.black, rightMask, ], stops: stops, ).createShader(bounds); }, child: Listener( onPointerSignal: (event) { if (event is PointerScrollEvent && _enfantsScrollController.hasClients) { final offset = _enfantsScrollController.offset + event.scrollDelta.dy; _enfantsScrollController.jumpTo(offset.clamp( _enfantsScrollController.position.minScrollExtent, _enfantsScrollController.position.maxScrollExtent, )); } }, child: ListView.builder( controller: _enfantsScrollController, scrollDirection: Axis.horizontal, itemCount: itemCount, itemBuilder: (_, i) => Padding( padding: EdgeInsets.only(right: i < itemCount - 1 ? 16 : 0), child: SizedBox( width: cardWidth, height: cardHeight, child: itemBuilder(i), ), ), ), ), ), ); }, ); } Widget _buildEnfantsStep() { if (_isCreate || _isEdit) { return _buildEnfantsStepCreate(); } final enfants = _dossier.enfants; if (enfants.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Text('Aucun enfant renseigné.', style: TextStyle(color: Colors.black87)), ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'Enfants', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), ), const SizedBox(height: 16), Expanded( child: _buildEnfantsHorizontalList( itemCount: enfants.length, itemBuilder: (i) => _buildEnfantCard(enfants[i]), ), ), ], ); } Widget _buildEnfantsStepCreate() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ const Text( 'Enfants', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87, ), ), const Spacer(), TextButton.icon( onPressed: _addChild, icon: const Icon(Icons.add), label: const Text('Ajouter un enfant'), ), ], ), const SizedBox(height: 8), Expanded( child: _buildEnfantsHorizontalList( itemCount: _children.length, itemBuilder: (i) => _buildCreateEnfantCard(i), ), ), ], ); } void _addChild() { setState(() => _children.add(_CreateChild())); WidgetsBinding.instance.addPostFrameCallback((_) { if (!_enfantsScrollController.hasClients) return; _enfantsScrollController.animateTo( _enfantsScrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }); } void _removeChild(int index) { if (_children.length <= 1) return; setState(() { final removed = _children.removeAt(index); final existingId = (removed.existingChildId ?? '').trim(); if (existingId.isNotEmpty) { _removedEnfantIds.add(existingId); } removed.dispose(); }); } Future _pickChildPhoto(int index) async { try { final picked = await ImagePicker().pickImage( source: ImageSource.gallery, maxWidth: 1200, imageQuality: 85, ); if (picked == null) return; final bytes = await picked.readAsBytes(); if (!mounted || index >= _children.length) return; setState(() { _children[index].photoBytes = bytes; _children[index].photoFilename = picked.name; }); } catch (_) { if (!mounted) return; _showError('Impossible de charger la photo.'); } } void _clearChildPhoto(int index) { if (index >= _children.length) return; setState(() { _children[index].photoBytes = null; _children[index].photoFilename = null; }); } /// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre). static const Color _enfantCardBoyBg = Color(0xFFF0F7FB); static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4); static const Color _enfantCardGirlBg = Color(0xFFFCF5F8); static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7); static const double _enfantCardRadius = 12; static List _enfantCardShadows() => [ BoxShadow( color: Colors.black.withOpacity(0.06), blurRadius: 14, offset: const Offset(0, 4), ), ]; static BoxDecoration _enfantCardDecoration(String? gender) { final g = (gender ?? '').trim().toUpperCase(); if (g == 'H') { return BoxDecoration( color: _enfantCardBoyBg, borderRadius: BorderRadius.circular(_enfantCardRadius), border: Border.all(color: _enfantCardBoyBorder, width: 1), boxShadow: _enfantCardShadows(), ); } if (g == 'F') { return BoxDecoration( color: _enfantCardGirlBg, borderRadius: BorderRadius.circular(_enfantCardRadius), border: Border.all(color: _enfantCardGirlBorder, width: 1), boxShadow: _enfantCardShadows(), ); } return BoxDecoration( color: Colors.grey.shade50, borderRadius: BorderRadius.circular(_enfantCardRadius), border: Border.all(color: Colors.grey.shade300), boxShadow: _enfantCardShadows(), ); } /// Carte enfant éditable (création) : prénom pleine largeur, puis photo 1/3 + champs 2/3. Widget _buildCreateEnfantCard(int index) { final child = _children[index]; final canRemove = _children.length > 1; return ClipRRect( borderRadius: BorderRadius.circular(_enfantCardRadius), child: Container( decoration: _enfantCardDecoration(child.genre), child: Stack( children: [ Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( // Réserve l’angle haut-droit pour la croix de suppression. padding: EdgeInsets.fromLTRB(12, 12, canRemove ? 36 : 14, 8), child: ValidationLabeledField( label: 'Prénom', field: ValidationEditableField( controller: child.prenomCtrl, hintText: child.isUnborn ? 'Facultatif' : null, inputFormatters: const [PersonNameInputFormatter()], ), ), ), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( flex: 1, child: LayoutBuilder( builder: (context, c) { const padL = 12.0; const padR = 8.0; const padV = 8.0; final maxW = (c.maxWidth - padL - padR) .clamp(0.0, double.infinity); final maxH = (c.maxHeight - 2 * padV) .clamp(0.0, double.infinity); const ar = _idPhotoAspectRatio; double ph = maxH; double pw = ph * ar; if (pw > maxW) { pw = maxW; ph = pw / ar; } return Padding( padding: const EdgeInsets.fromLTRB( padL, padV, padR, padV), child: Align( alignment: Alignment.centerLeft, child: SizedBox( width: pw, height: ph, child: AmPhotoFrame( photoUrl: child.photoBytes == null ? child.existingPhotoUrl : null, imageBytes: child.photoBytes, onTap: () => _pickChildPhoto(index), onClear: child.photoBytes != null ? () => _clearChildPhoto(index) : null, emptyLabel: 'Ajouter une photo', ), ), ), ); }, ), ), Expanded( flex: 2, child: Padding( padding: const EdgeInsets.fromLTRB(4, 0, 14, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ValidationLabeledField( label: 'Nom', field: ValidationEditableField( controller: child.nomCtrl, inputFormatters: const [ PersonNameInputFormatter(), ], ), ), const SizedBox(height: 6), ValidationLabeledField( 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', keyboardType: TextInputType.number, inputFormatters: const [ FrenchDateInputFormatter(), ], ), ), const SizedBox(height: 6), ValidationLabeledField( label: 'Genre', field: _genreDropdown(index), ), ], ), ), ), ], ), ), ], ), if (canRemove) Positioned( top: 6, right: 6, child: Material( color: Colors.white, elevation: 1, shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: InkWell( customBorder: const CircleBorder(), onTap: () => _removeChild(index), child: Tooltip( message: 'Retirer cet enfant', child: SizedBox( width: 22, height: 22, child: Icon( Icons.close, size: 14, color: Colors.grey.shade800, ), ), ), ), ), ), ], ), ), ); } 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( 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: items, onChanged: (v) => setState(() => child.genre = v), ), ); } /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). Widget _buildEnfantCard(EnfantDossier e) { final photoUrl = _fullPhotoUrl(e.photoUrl); final columnStatusLabel = _enfantColumnStatusLabel(e); return ClipRRect( borderRadius: BorderRadius.circular(_enfantCardRadius), child: Container( decoration: _enfantCardDecoration(e.gender), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: _enfantLabeledField('Prénom', _v(e.firstName)), ), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( flex: 1, child: LayoutBuilder( builder: (context, c) { // Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8. const padL = 12.0; const padR = 8.0; const padV = 8.0; final maxW = (c.maxWidth - padL - padR).clamp(0.0, double.infinity); final maxH = (c.maxHeight - 2 * padV).clamp(0.0, double.infinity); const ar = _idPhotoAspectRatio; double ph = maxH; double pw = ph * ar; if (pw > maxW) { pw = maxW; ph = pw / ar; } return Padding( padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV), child: Align( alignment: Alignment.centerLeft, child: _buildEnfantPhotoSlot(photoUrl, pw, ph), ), ); }, ), ), Expanded( flex: 2, child: Padding( padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), child: columnStatusLabel == null ? SingleChildScrollView( child: _buildEnfantInfoFields(e), ) : CustomScrollView( slivers: [ SliverToBoxAdapter( child: _buildEnfantInfoFields(e), ), SliverFillRemaining( hasScrollBody: false, child: Center( child: Text( columnStatusLabel, textAlign: TextAlign.center, style: GoogleFonts.merienda( fontSize: 14, fontStyle: FontStyle.italic, fontWeight: FontWeight.w600, color: Colors.grey.shade800, ), ), ), ), ], ), ), ), ], ), ), ], ), ), ); } /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). String? _enfantColumnStatusLabel(EnfantDossier e) { return enfantColumnStatusLabel(status: e.status, gender: e.gender); } /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). Widget _buildEnfantInfoFields(EnfantDossier e) { final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty; final dateValue = isANaitre ? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)') : _formatBirthDate(e.birthDate); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.only(bottom: 12), child: _enfantLabeledField('Nom', _formatNom(e.lastName)), ), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 3, child: _enfantLabeledField('Date de naissance', dateValue), ), const SizedBox(width: 16), Expanded( flex: 2, child: _enfantLabeledField( 'Genre', _genreEnfantLabel(e.gender, e.status), ), ), ], ), ], ); } Widget _enfantLabeledField(String label, String value) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Colors.grey.shade700, ), ), const SizedBox(height: 4), ValidationReadOnlyField(value: value), ], ); } Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { const photoRadius = 8.0; return Container( width: width, height: height, decoration: BoxDecoration( borderRadius: BorderRadius.circular(photoRadius), border: Border.all(color: Colors.black.withOpacity(0.08)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), blurRadius: 6, offset: const Offset(0, 2), ), ], ), clipBehavior: Clip.antiAlias, child: photoUrl.isEmpty ? ColoredBox( color: Colors.grey.shade100, child: Center( child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), ), ) : AuthNetworkImage( url: photoUrl, fit: BoxFit.cover, width: width, height: height, errorBuilder: (_, __, ___) => ColoredBox( color: Colors.grey.shade100, child: Center( child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400), ), ), ), ); } static String _formatNom(String? lastName) { final n = (lastName ?? '').trim().toUpperCase(); return n.isEmpty ? 'Non défini' : n; } /// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître). static String _genreEnfantLabel(String? gender, String? status) { final g = (gender ?? '').trim().toUpperCase(); final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre'; if (g == 'H') return 'Garçon'; if (g == 'F') return 'Fille'; if (isANaitre) return 'Non connu'; if (g.isEmpty) return 'Non défini'; return (gender ?? '').trim(); } Widget _buildPresentationStep() { if (_isCreate) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'Présentation', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87, ), ), const SizedBox(height: 12), Expanded( child: TextField( controller: _presentationCtrl, maxLines: null, expands: true, maxLength: 2000, textAlignVertical: TextAlignVertical.top, decoration: InputDecoration( hintText: 'Présentation (optionnel, max 2000 caractères)', filled: true, fillColor: Colors.grey.shade50, border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: Colors.grey.shade300), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: Colors.grey.shade300), ), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), ), ), ), ], ); } final p = _dossier.presentation ?? ''; final text = p.trim().isEmpty ? 'Non défini' : p; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'Présentation', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), ), const SizedBox(height: 12), Expanded( child: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.grey.shade50, borderRadius: BorderRadius.circular(6), border: Border.all(color: Colors.grey.shade300), ), child: SelectableText( text, style: const TextStyle(color: Colors.black87, fontSize: 14), ), ), ), ); }, ), ), ], ); } // --- Validation (mode création) --- String? _validateP1() { final nom = _p1NomCtrl.text.trim(); final prenom = _p1PrenomCtrl.text.trim(); if (nom.length < 2) { return 'Le nom du parent principal doit contenir au moins 2 caractères.'; } if (prenom.length < 2) { return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; } final phoneErr = validateFrenchNationalPhone(_p1TelCtrl.text); if (phoneErr != null) return phoneErr; final emailErr = validateEmail(_p1EmailCtrl.text); if (emailErr != null) return emailErr; if (_p1AdresseCtrl.text.trim().isEmpty) { return 'L’adresse du parent principal est requise.'; } final cpErr = validateFrenchPostalCode(_p1CpCtrl.text); if (cpErr != null) return cpErr; if (_p1VilleCtrl.text.trim().isEmpty) { return 'La ville du parent principal est requise.'; } return null; } String? _validateP2() { if (!_hasCoParent) return null; final nom = _p2NomCtrl.text.trim(); final prenom = _p2PrenomCtrl.text.trim(); if (nom.length < 2) { return 'Le nom du co-parent doit contenir au moins 2 caractères.'; } if (prenom.length < 2) { return 'Le prénom du co-parent doit contenir au moins 2 caractères.'; } final phoneErr = validateFrenchNationalPhone(_p2TelCtrl.text); if (phoneErr != null) return 'Co-parent : $phoneErr'; final emailErr = validateEmail(_p2EmailCtrl.text); if (emailErr != null) return 'Co-parent : $emailErr'; if (normalizeEmailText(_p2EmailCtrl.text) == 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.'; } final cpErr = validateFrenchPostalCode(_p2CpCtrl.text); if (cpErr != null) return 'Co-parent : $cpErr'; if (_p2VilleCtrl.text.trim().isEmpty) { return 'La ville du co-parent est requise.'; } return null; } String? _validateEnfants() { if (_children.isEmpty) return 'Au moins un enfant est requis.'; for (var i = 0; i < _children.length; i++) { final c = _children[i]; final label = 'Enfant ${i + 1}'; 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; } String? _validateCurrentStep() { if (_isEdit) { switch (_step) { case 0: return _validateP1(); case 1: return _validateP2(); case 2: return _validateEnfants(); default: return null; } } if (!_isCreate) return null; switch (_step) { case 0: return _validateP1(); case 1: return _validateP2(); case 2: return _validateEnfants(); default: return null; } } void _goNext() { final err = _validateCurrentStep(); if (err != null) { _showError(err); return; } setState(() => _step++); _emitStep(); } static String _imageMimeForBytes(Uint8List bytes) { if (bytes.length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) { return 'image/png'; } if (bytes.length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) { return 'image/jpeg'; } if (bytes.length >= 6 && bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) { return 'image/gif'; } if (bytes.length >= 12 && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46) { return 'image/webp'; } return 'image/jpeg'; } Map _childCreateToJson(_CreateChild c) { final prenom = formatPersonNameCase(c.prenomCtrl.text); final nomRaw = c.nomCtrl.text.trim(); final nom = nomRaw.isNotEmpty ? formatPersonNameCase(nomRaw) : formatPersonNameCase(_p1NomCtrl.text); final dateIso = parseFrDateToIso(c.dateCtrl.text); final map = { 'genre': c.genre, 'consent_photo': true, }; 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); final fn = (c.photoFilename ?? '').trim(); map['photo_base64'] = 'data:$mime;base64,${base64Encode(bytes)}'; map['photo_filename'] = fn.isNotEmpty ? fn : 'enfant.jpg'; } return map; } Map _buildCreateBody() { final body = { 'email': normalizeEmailText(_p1EmailCtrl.text), 'prenom': formatPersonNameCase(_p1PrenomCtrl.text), 'nom': formatPersonNameCase(_p1NomCtrl.text), 'telephone': normalizePhone(_p1TelCtrl.text), 'adresse': _p1AdresseCtrl.text.trim(), 'code_postal': _p1CpCtrl.text.trim(), 'ville': formatPersonNameCase(_p1VilleCtrl.text), 'acceptation_cgu': true, 'acceptation_privacy': true, }; if (_hasCoParent) { 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'] = _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); } } final presentation = _presentationCtrl.text.trim(); if (presentation.isNotEmpty) { body['presentation_dossier'] = presentation; } body['enfants'] = _children.map(_childCreateToJson).toList(); return body; } Future _createAndValidate() async { if (_submitting) return; final err0 = _validateP1(); if (err0 != null) { _showError(err0); return; } final err1 = _validateP2(); if (err1 != null) { _showError(err1); return; } final err2 = _validateEnfants(); if (err2 != null) { _showError(err2); return; } final ok = await showValidationValiderConfirmDialog( context, body: 'Créer et activer ce dossier famille ? Un e-mail de création de mot de passe sera envoyé à chaque parent créé.', ); if (!mounted || !ok) return; setState(() => _submitting = true); try { await UserService.createParentDossier(_buildCreateBody()); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Dossier créé et validé. La famille est active.'), duration: Duration(seconds: 4), ), ); widget.onSuccess(); } catch (e) { if (!mounted) return; _showError( e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', ); } finally { if (mounted) setState(() => _submitting = false); } } Map _parentFicheBody({ required TextEditingController nom, required TextEditingController prenom, required TextEditingController email, required TextEditingController tel, required TextEditingController adresse, required TextEditingController cp, required TextEditingController ville, }) { return { 'nom': formatPersonNameCase(nom.text), 'prenom': formatPersonNameCase(prenom.text), 'email': normalizeEmailText(email.text), 'telephone': normalizePhone(tel.text), 'adresse': adresse.text.trim(), 'ville': formatPersonNameCase(ville.text), 'code_postal': cp.text.trim(), }; } Future _saveEdit() async { if (_submitting || !_isEdit) return; final err0 = _validateP1(); if (err0 != null) { _showError(err0); return; } final err1 = _validateP2(); if (err1 != null) { _showError(err1); return; } final err2 = _validateEnfants(); if (err2 != null) { _showError(err2); return; } final pivotId = (_firstParentId ?? '').trim(); if (pivotId.isEmpty) { _showError('Identifiant du parent principal manquant.'); return; } final addingCoParent = _hasCoParent && !_hadExistingCoParent; final ok = await showValidationValiderConfirmDialog( context, body: addingCoParent ? 'Enregistrer le dossier et ajouter le co-parent ? Un e-mail de création de mot de passe lui sera envoyé.' : 'Enregistrer les modifications du dossier famille ?', ); if (!mounted || !ok) return; setState(() => _submitting = true); try { await UserService.updateParentFiche( parentUserId: pivotId, body: _parentFicheBody( nom: _p1NomCtrl, prenom: _p1PrenomCtrl, email: _p1EmailCtrl, tel: _p1TelCtrl, adresse: _p1AdresseCtrl, cp: _p1CpCtrl, ville: _p1VilleCtrl, ), ); if (_hadExistingCoParent) { final coId = _dossier.parents[1].id.trim(); if (coId.isEmpty) { throw Exception('Identifiant du co-parent manquant.'); } await UserService.updateParentFiche( parentUserId: coId, body: _parentFicheBody( nom: _p2NomCtrl, prenom: _p2PrenomCtrl, email: _p2EmailCtrl, tel: _p2TelCtrl, adresse: _p2AdresseCtrl, cp: _p2CpCtrl, ville: _p2VilleCtrl, ), ); } else if (addingCoParent) { if (_sameAddress) _copyP1AddressToP2(); final body = { 'email': normalizeEmailText(_p2EmailCtrl.text), 'prenom': formatPersonNameCase(_p2PrenomCtrl.text), 'nom': formatPersonNameCase(_p2NomCtrl.text), 'telephone': normalizePhone(_p2TelCtrl.text), 'meme_adresse': _sameAddress, }; if (!_sameAddress) { body['adresse'] = _p2AdresseCtrl.text.trim(); body['code_postal'] = _p2CpCtrl.text.trim(); body['ville'] = formatPersonNameCase(_p2VilleCtrl.text); } await UserService.addCoParent(pivotId, body: body); } await _saveEnfantsEdit(pivotId); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( addingCoParent ? 'Dossier enregistré. Co-parent ajouté.' : 'Dossier enregistré.', ), duration: const Duration(seconds: 4), ), ); widget.onSuccess(); } catch (e) { if (!mounted) return; _showError( e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', ); } finally { if (mounted) setState(() => _submitting = false); } } Map _enfantStaffBody(_CreateChild c) { final prenom = formatPersonNameCase(c.prenomCtrl.text); final nomRaw = c.nomCtrl.text.trim(); final nom = nomRaw.isNotEmpty ? formatPersonNameCase(nomRaw) : formatPersonNameCase(_p1NomCtrl.text); final dateIso = parseFrDateToIso(c.dateCtrl.text); final existingStatus = (c.existingStatus ?? '').trim().toLowerCase(); final status = c.isUnborn ? 'a_naitre' : (existingStatus.isNotEmpty && existingStatus != 'a_naitre' ? existingStatus : 'sans_garde'); final gender = c.genre ?? 'H'; final map = { 'status': status, 'gender': gender, 'consent_photo': true, }; if (prenom.length >= 2) map['first_name'] = prenom; if (nom.length >= 2) map['last_name'] = nom; if (c.isUnborn) { if (dateIso != null) map['due_date'] = dateIso; } else if (dateIso != null) { map['birth_date'] = dateIso; } return map; } /// Empreinte identité pour rattacher un brouillon sans id à un enfant du GET. String _enfantIdentityKey({ required String prenom, required String nom, required String? dateIso, required bool isUnborn, }) { final pn = formatPersonNameCase(prenom).toLowerCase().trim(); final nm = formatPersonNameCase(nom).toLowerCase().trim(); final d = _normalizeDateKey(dateIso); final kind = isUnborn ? 'due' : 'birth'; return '$pn|$nm|$kind|$d'; } String _normalizeDateKey(String? raw) { final s = (raw ?? '').trim(); if (s.isEmpty) return ''; final fromFr = parseFrDateToIso(s); if (fromFr != null) return fromFr; try { final dt = DateTime.parse(s); final y = dt.year.toString().padLeft(4, '0'); final m = dt.month.toString().padLeft(2, '0'); final d = dt.day.toString().padLeft(2, '0'); return '$y-$m-$d'; } catch (_) { return s; } } String _enfantKeyFromCreate(_CreateChild c) { final prenom = formatPersonNameCase(c.prenomCtrl.text); final nomRaw = c.nomCtrl.text.trim(); final nom = nomRaw.isNotEmpty ? formatPersonNameCase(nomRaw) : formatPersonNameCase(_p1NomCtrl.text); return _enfantIdentityKey( prenom: prenom, nom: nom, dateIso: parseFrDateToIso(c.dateCtrl.text), isUnborn: c.isUnborn, ); } String _enfantKeyFromDossier(EnfantDossier e) { final isUnborn = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; final nomRaw = (e.lastName ?? '').trim(); final nom = nomRaw.isNotEmpty ? nomRaw : (_p1NomCtrl.text.trim()); return _enfantIdentityKey( prenom: e.firstName ?? '', nom: nom, dateIso: isUnborn ? e.dueDate : e.birthDate, isUnborn: isUnborn, ); } bool _isBlankChildDraft(_CreateChild c) { if (c.hasExistingId) return false; final prenom = c.prenomCtrl.text.trim(); final nom = c.nomCtrl.text.trim(); final date = c.dateCtrl.text.trim(); return prenom.isEmpty && nom.isEmpty && date.isEmpty && c.genre == null; } /// Résout l’id enfant : tracker edit, sinon match sur le GET dossier. String? _resolveExistingChildId( _CreateChild c, Map dossierIdByIdentity, Set alreadyUsedIds, ) { final tracked = (c.existingChildId ?? '').trim(); if (tracked.isNotEmpty && !alreadyUsedIds.contains(tracked)) { return tracked; } final key = _enfantKeyFromCreate(c); final matched = (dossierIdByIdentity[key] ?? '').trim(); if (matched.isNotEmpty && !alreadyUsedIds.contains(matched)) { return matched; } return null; } Future _saveEnfantsEdit(String pivotUserId) async { // Index id connus du GET (ne jamais POST pour ceux-là). final dossierIdByIdentity = {}; for (final e in _dossier.enfants) { final id = e.id.trim(); if (id.isEmpty) continue; dossierIdByIdentity[_enfantKeyFromDossier(e)] = id; } for (final id in _removedEnfantIds) { await UserService.deleteEnfant(id); } final usedIds = {..._removedEnfantIds}; for (final c in _children) { if (_isBlankChildDraft(c)) continue; final existingId = _resolveExistingChildId( c, dossierIdByIdentity, usedIds, ); final body = _enfantStaffBody(c); if (existingId != null && existingId.isNotEmpty) { // Ré-attache l’id au modèle (si match identité a récupéré un id perdu). c.existingChildId = existingId; final bytes = c.photoBytes; await UserService.updateEnfant( enfantId: existingId, body: body, photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null, photoFilename: c.photoFilename, ); usedIds.add(existingId); continue; } // Uniquement les vrais nouveaux enfants (pas d’id dossier). final bytes = c.photoBytes; final created = await UserService.createEnfant( parentUserId: pivotUserId, body: body, photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null, photoFilename: c.photoFilename, ); final newId = created.id.trim(); if (newId.isNotEmpty) { c.existingChildId = newId; usedIds.add(newId); } } } Widget _buildNavigation() { if (_step == 3) { return Row( children: [ TextButton(onPressed: widget.onClose, child: const Text('Annuler')), const Spacer(), Row( mainAxisSize: MainAxisSize.min, children: [ TextButton( onPressed: () { setState(() => _step = 2); _emitStep(); }, child: const Text('Précédent'), ), const SizedBox(width: 8), if (_isCreate) ...[ ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, onPressed: _submitting ? null : _createAndValidate, child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), ), ] else if (_isEdit) ...[ ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, onPressed: _submitting ? null : _saveEdit, child: Text(_submitting ? 'Envoi...' : 'Enregistrer'), ), ] else if (_isEnAttente && _firstParentId != null) ...[ OutlinedButton( onPressed: _submitting ? null : _refuser, child: const Text('Refuser')), const SizedBox(width: 12), ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, onPressed: _submitting ? null : _onValiderPressed, child: Text(_submitting ? 'Envoi...' : 'Valider'), ), ] else if (!_isEnAttente) ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, onPressed: widget.onClose, child: const Text('Fermer'), ), ], ), ], ); } return Row( children: [ TextButton(onPressed: widget.onClose, child: const Text('Annuler')), const Spacer(), if (_step > 0) ...[ TextButton( onPressed: () { setState(() => _step--); _emitStep(); }, child: const Text('Précédent'), ), const SizedBox(width: 8), ], ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, onPressed: _goNext, child: const Text('Suivant'), ), ], ); } Future _onValiderPressed() async { if (_submitting || _firstParentId == null) return; final ok = await showValidationValiderConfirmDialog( context, body: 'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.', ); if (!mounted || !ok) return; await _valider(); } Future _valider() async { if (_submitting || _firstParentId == null) return; setState(() => _submitting = true); try { await UserService.validerDossierFamille(_firstParentId!); if (!mounted) return; widget.onSuccess(); } catch (e) { if (!mounted) return; _showError( e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', ); } finally { if (mounted) setState(() => _submitting = false); } } void _refuser() => setState(() => _showRefusForm = true); Widget _buildRefusPage() { return Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: ValidationRefusForm( isSubmitting: _submitting, onCancel: widget.onClose, onPrevious: () => setState(() => _showRefusForm = false), onSubmit: (comment) { if (comment == null || comment.trim().isEmpty) return; _refuserEnvoyer(comment.trim()); }, ), ), ], ), ); } /// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110. Future _refuserEnvoyer(String comment) async { if (_submitting) return; final parentId = _firstParentId?.trim(); if (parentId == null || parentId.isEmpty || !_isEnAttente) { if (!mounted) return; _showError('Aucun compte en attente à refuser pour ce dossier.'); return; } setState(() => _submitting = true); try { await UserService.refuseUser(parentId, comment: comment); if (!mounted) return; final nbEnAttente = _dossier.parents.where((p) => p.statut == 'en_attente').length; final msg = nbEnAttente > 1 ? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.' : 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.'; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(msg), duration: const Duration(seconds: 4), ), ); widget.onSuccess(); } catch (e) { if (!mounted) return; _showError( e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', ); } finally { if (mounted) setState(() => _submitting = false); } } }