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/admin/common/admin_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 } /// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer. class _CreateChild { final TextEditingController prenomCtrl = TextEditingController(); final TextEditingController nomCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController(); String? genre; bool isUnborn = false; Uint8List? photoBytes; String? photoFilename; void dispose() { prenomCtrl.dispose(); nomCtrl.dispose(); dateCtrl.dispose(); } } /// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107) /// et [create] (création dossier famille actif par le staff, ticket #129). 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, ); } bool get isCreate => mode == ParentDossierWizardMode.create; /// 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; bool get _isCreate => widget.isCreate; DossierFamille get _dossier => widget.dossier!; bool get _isEnAttente => !_isCreate && _dossier.isEnAttente; String? get _firstParentId { if (_isCreate) return null; return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null; } 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()); } WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); } @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 (_isCreate) { 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 (_isCreate) { return _buildCoParentStepCreate(); } return _buildParent2Step(); } Widget _buildCoParentStepCreate() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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( 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) { 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); 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( 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()], ), ), ), 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: AdminAmPhotoFrame( 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, 4, 14, 12), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ValidationLabeledField( label: 'Nom', field: ValidationEditableField( controller: child.nomCtrl, inputFormatters: const [ PersonNameInputFormatter(), ], ), ), const SizedBox(height: 12), 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: 12), ValidationLabeledField( label: 'Genre', field: _genreDropdown(index), ), ], ), ), ), ), ], ), ), ], ), if (canRemove) Positioned( top: 4, right: 4, child: Material( color: Colors.transparent, child: IconButton( tooltip: 'Retirer cet enfant', visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), icon: Icon(Icons.close, size: 18, color: Colors.grey.shade700), onPressed: () => _removeChild(index), ), ), ), ], ), ), ); } 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 (!_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, '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); 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); } } 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 (_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); } } }