import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; import 'validation_valider_confirm_dialog.dart'; /// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. class ValidationFamilyWizard extends StatefulWidget { final DossierFamille dossier; final VoidCallback onClose; final VoidCallback onSuccess; final void Function(int step, int total)? onStepChanged; const ValidationFamilyWizard({ super.key, required this.dossier, required this.onClose, required this.onSuccess, this.onStepChanged, }); @override State createState() => _ValidationFamilyWizardState(); } class _ValidationFamilyWizardState 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; int get _stepCount => 4; @override void initState() { super.initState(); _enfantsScrollController.addListener(_syncEnfantsScrollFades); WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); } @override void dispose() { _enfantsScrollController.removeListener(_syncEnfantsScrollFades); _enfantsScrollController.dispose(); super.dispose(); } void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); 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; }); } } bool get _isEnAttente => widget.dossier.isEnAttente; String? get _firstParentId => widget.dossier.parents.isNotEmpty ? widget.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) { if (s == null || s.trim().isEmpty) return 'Non défini'; try { final d = DateTime.parse(s.trim()); return DateFormat('dd/MM/yyyy').format(d); } catch (_) { return s.trim(); } } /// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville). List _parentFields(ParentDossier p) => [ AdminDetailField(label: 'Nom', value: _v(p.nom)), AdminDetailField(label: 'Prénom', value: _v(p.prenom)), AdminDetailField( label: 'Téléphone', value: _v(p.telephone) != 'Non défini' ? formatPhoneForDisplay(_v(p.telephone)) : 'Non défini'), AdminDetailField(label: 'Email', value: _v(p.email)), AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)), AdminDetailField(label: 'Code postal', value: _v(p.codePostal)), AdminDetailField(label: 'Ville', value: _v(p.ville)), ]; static const List _parentRowLayout = [2, 2, 1, 2]; static const Map> _parentRowFlex = { 3: [2, 5] }; // Code postal étroit, Ville large static String _fullPhotoUrl(String? url) { if (url == null || url.trim().isEmpty) return ''; final u = url.trim(); if (u.startsWith('http://') || u.startsWith('https://')) return u; final base = ApiConfig.baseUrl; final origin = base.replaceAll(RegExp(r'/api/v1.*'), ''); return u.startsWith('/') ? '$origin$u' : '$origin/$u'; } @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() { final d = widget.dossier; switch (_step) { case 0: return ValidationDetailSection( title: 'Parent principal', fields: _parentFields(d.parents.first), rowLayout: _parentRowLayout, rowFlex: _parentRowFlex, ); case 1: return _buildParent2Step(); case 2: return _buildEnfantsStep(); case 3: return _buildPresentationStep(); default: return const SizedBox(); } } Widget _buildParent2Step() { if (widget.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 ValidationDetailSection( title: 'Deuxième parent', fields: _parentFields(widget.dossier.parents[1]), rowLayout: _parentRowLayout, rowFlex: _parentRowFlex, ); } static const double _idPhotoAspectRatio = 35 / 45; Widget _buildEnfantsStep() { final enfants = widget.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: 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: enfants.length, itemBuilder: (_, i) => Padding( padding: EdgeInsets.only( right: i < enfants.length - 1 ? 16 : 0), child: SizedBox( width: cardWidth, height: cardHeight, child: _buildEnfantCard(enfants[i]), ), ), ), ), ), ); }, ), ), ], ); } /// 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.withValues(alpha: 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 : 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, ), ), ), ), ], ), ), ), ], ), ), ], ), ), ); } /// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut). static String _scolariseAccordeAuGenre(String? gender) { final g = (gender ?? '').trim().toUpperCase(); if (g == 'F') return 'Scolarisée'; return 'Scolarisé'; } /// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ». /// `actif` : pas de ligne statut. String? _enfantColumnStatusLabel(EnfantDossier e) { final s = (e.status ?? '').trim().toLowerCase(); if (s == 'a_naitre') return 'À naître'; if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender); return null; } /// 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) { return Container( width: width, height: height, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.black.withValues(alpha: 0.08)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 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), ), ) : Image.network( photoUrl, fit: BoxFit.contain, 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() { final p = widget.dossier.presentation ?? ''; final text = p.trim().isEmpty ? 'Non défini' : p; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'Présentation / Motivation', 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), ), ), ), ); }, ), ), ], ); } 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 (_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: () { setState(() => _step++); _emitStep(); }, 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; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur'), backgroundColor: Colors.red.shade700, ), ); } 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( onCancel: widget.onClose, onPrevious: () => setState(() => _showRefusForm = false), onSubmit: (comment) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Refus (à brancher sur l’API refus – ticket #110)')), ); widget.onClose(); }, ), ), ], ), ); } }