import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/models/user.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 AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. class ValidationAmWizard extends StatefulWidget { final DossierAM dossier; final VoidCallback onClose; final VoidCallback onSuccess; final void Function(int step, int total)? onStepChanged; const ValidationAmWizard({ super.key, required this.dossier, required this.onClose, required this.onSuccess, this.onStepChanged, }); @override State createState() => _ValidationAmWizardState(); } class _ValidationAmWizardState extends State { int _step = 0; bool _showRefusForm = false; bool _submitting = false; static const int _stepCount = 3; bool get _isEnAttente => widget.dossier.user.statut == 'en_attente'; static String _v(String? s) => (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; /// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis). static String _formatNirForDisplay(String? nir) { final v = _v(nir); if (v == '–') return v; final raw = nirToRaw(v).toUpperCase(); return raw.length == 15 ? formatNir(raw) : v; } @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); } void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); /// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville). List _personalFields(AppUser u) => [ AdminDetailField(label: 'Nom', value: _v(u.nom)), AdminDetailField(label: 'Prénom', value: _v(u.prenom)), AdminDetailField( label: 'Téléphone', value: _v(u.telephone) != '–' ? formatPhoneForDisplay(_v(u.telephone)) : '–'), AdminDetailField(label: 'Email', value: _v(u.email)), AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)), AdminDetailField(label: 'Code postal', value: _v(u.codePostal)), AdminDetailField(label: 'Ville', value: _v(u.ville)), ]; /// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville. List _proFields(DossierAM d) => [ AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), AdminDetailField( label: 'Date d’agrément', value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty ? d.dateAgrement!.trim() : '–', ), AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), AdminDetailField( label: 'Capacité max (enfants)', value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', ), AdminDetailField( label: 'Places disponibles', value: d.placesDisponibles != null ? d.placesDisponibles.toString() : '–', ), AdminDetailField( label: 'Ville de résidence', value: _v(d.villeResidence)), ]; static const List _personalRowLayout = [2, 2, 1, 2]; static const Map> _personalRowFlex = { 3: [2, 5] }; // Code postal étroit, Ville large /// Proportion photo d’identité (35×45 mm). static const double _idPhotoAspectRatio = 35 / 45; static const double _photoProGap = 24; /// Largeur mini réservée aux champs (évite une colonne photo trop gourmande). static const double _proColumnMinWidth = 260; static const double _photoColumnMinWidth = 160; /// URL complète pour la photo : si relatif, on préfixe par l’origine de l’API. 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'; } Widget _buildPhotoSection(AppUser u) { final photoUrl = _fullPhotoUrl(u.photoUrl); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'Photo de profil', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87, ), ), const SizedBox(height: 12), Expanded( child: Padding( padding: const EdgeInsets.only(right: 8), child: LayoutBuilder( builder: (context, c) { // Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite). const uniformFrame = 8.0; final maxPhotoW = (c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity); final maxPhotoH = (c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity); const ar = _idPhotoAspectRatio; double ph = maxPhotoH; double pw = ph * ar; if (pw > maxPhotoW) { pw = maxPhotoW; ph = pw / ar; } return Align( alignment: Alignment.center, child: Container( decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300), ), clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.all(uniformFrame), child: ClipRRect( borderRadius: BorderRadius.circular(6), child: SizedBox( width: pw, height: ph, child: photoUrl.isEmpty ? ColoredBox( color: Colors.grey.shade200, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.person_off_outlined, size: 40, color: Colors.grey.shade400), const SizedBox(height: 8), Text( 'Aucune photo fournie', style: TextStyle( color: Colors.grey.shade600, fontSize: 12), ), ], ), ) : Image.network( photoUrl, fit: BoxFit.cover, width: pw, height: ph, loadingBuilder: (_, child, progress) { if (progress == null) return child; return ColoredBox( color: Colors.grey.shade200, child: Center( child: CircularProgressIndicator( value: progress.expectedTotalBytes != null ? progress.cumulativeBytesLoaded / (progress.expectedTotalBytes!) : null, ), ), ); }, errorBuilder: (_, __, ___) => ColoredBox( color: Colors.grey.shade200, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.broken_image_outlined, size: 40, color: Colors.grey.shade400), const SizedBox(height: 8), Text( 'Impossible de charger la photo', style: TextStyle( color: Colors.grey.shade600, fontSize: 12), ), ], ), ), ), ), ), ), ), ); }, ), ), ), ], ); } @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; final u = d.user; switch (_step) { case 0: return LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), child: ValidationDetailSection( title: 'Informations personnelles', fields: _personalFields(u), rowLayout: _personalRowLayout, rowFlex: _personalRowFlex, ), ), ); }, ); case 1: // Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite. // Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs. return LayoutBuilder( builder: (context, c) { final maxRowW = c.maxWidth; final maxRowH = c.maxHeight; // Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo. const photoHeaderH = 52.0; final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity); final idealPhotoW = bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) .clamp(0.0, double.infinity); var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0); if (photoW > maxPhotoW) photoW = maxPhotoW; photoW = photoW.clamp(0.0, maxRowW - _photoProGap); return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: photoW, child: _buildPhotoSection(u), ), const SizedBox(width: _photoProGap), Expanded( child: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( minWidth: constraints.maxWidth), child: ValidationDetailSection( title: 'Informations professionnelles', fields: _proFields(d), rowLayout: const [ 2, 1, 2, 1 ], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville ), ), ); }, ), ), ], ); }, ); case 2: final presentation = (d.presentation != null && d.presentation!.trim().isNotEmpty) ? d.presentation! : '–'; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Présentation', style: const 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( presentation, style: const TextStyle( color: Colors.black87, fontSize: 14), ), ), ), ); }, ), ), ], ); default: return const SizedBox(); } } Widget _buildNavigation() { if (_step == 2) { return Row( children: [ TextButton(onPressed: widget.onClose, child: const Text('Annuler')), const Spacer(), Row( mainAxisSize: MainAxisSize.min, children: [ TextButton( onPressed: () { setState(() => _step = 1); _emitStep(); }, child: const Text('Précédent'), ), const SizedBox(width: 8), if (_isEnAttente) ...[ 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 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) return; final ok = await showValidationValiderConfirmDialog( context, body: 'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.', ); if (!mounted || !ok) return; await _valider(); } Future _valider() async { if (_submitting) return; setState(() => _submitting = true); try { await UserService.validateUser(widget.dossier.user.id); 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)')), ); widget.onClose(); }, ), ), ], ), ); } }