petitspas/frontend/lib/widgets/admin/validation_am_wizard.dart
Julien Martin cde676c4f9 feat: alignement master sur develop (squash)
- Dossiers unifiés #119, pending-families enrichi, validation admin (wizards)
- Front: modèles dossier_unifie / pending_family, NIR, auth
- Migrations dossier_famille, scripts de test API
- Résolution conflits: parents.*, docs tickets, auth_service, nir_utils

Made-with: Cursor
2026-03-26 00:20:47 +01:00

508 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ValidationAmWizard> createState() => _ValidationAmWizardState();
}
class _ValidationAmWizardState extends State<ValidationAmWizard> {
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<AdminDetailField> _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<AdminDetailField> _proFields(DossierAM d) => [
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField(
label: 'Date dagré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<int> _personalRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _personalRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
/// Proportion photo didentité (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 lorigine de lAPI.
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<void> _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<void> _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 lAPI refus)')),
);
widget.onClose();
},
),
),
],
),
);
}
}