petitspas/frontend/lib/widgets/admin/am_dossier_wizard.dart
Julien Martin ae610733cc feat(#156): création dossier AM staff — API + wizard dashboard.
Squash depuis develop : POST /assistantes-maternelles/dossier (actif +
mail MDP), AmDossierWizard create/review, validations inscription.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:53:18 +02:00

966 lines
32 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 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:p_tits_pas/models/am_registration_data.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/models/user.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/name_format_utils.dart';
import 'package:p_tits_pas/utils/nir_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/admin_detail_modal.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 AmDossierWizardMode { review, create }
/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156).
class AmDossierWizard extends StatefulWidget {
final AmDossierWizardMode mode;
final DossierAM? dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const AmDossierWizard._({
super.key,
required this.mode,
this.dossier,
required this.onClose,
required this.onSuccess,
this.onStepChanged,
});
factory AmDossierWizard.review({
Key? key,
required DossierAM dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return AmDossierWizard._(
key: key,
mode: AmDossierWizardMode.review,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
factory AmDossierWizard.create({
Key? key,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return AmDossierWizard._(
key: key,
mode: AmDossierWizardMode.create,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == AmDossierWizardMode.create;
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
static double get shellBodyHeight =>
ValidationFormMetrics.shellBodyHeightForRows(4);
@override
State<AmDossierWizard> createState() => _AmDossierWizardState();
}
class _AmDossierWizardState extends State<AmDossierWizard> {
int _step = 0;
bool _showRefusForm = false;
bool _submitting = false;
static const int _stepCount = 3;
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
static const double _idPhotoAspectRatio = 35 / 45;
static const double _photoProGap = 24;
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
// --- Create mode controllers / state ---
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _telCtrl;
late final TextEditingController _emailCtrl;
late final TextEditingController _adresseCtrl;
late final TextEditingController _cpCtrl;
late final TextEditingController _villeCtrl;
late final TextEditingController _nirCtrl;
late final TextEditingController _dateNaissanceCtrl;
late final TextEditingController _lieuNaissanceVilleCtrl;
late final TextEditingController _lieuNaissancePaysCtrl;
late final TextEditingController _agrementCtrl;
late final TextEditingController _dateAgrementCtrl;
late final TextEditingController _capaciteCtrl;
late final TextEditingController _placesCtrl;
late final TextEditingController _presentationCtrl;
Uint8List? _photoBytes;
String? _photoFilename;
bool get _isCreate => widget.isCreate;
DossierAM get _dossier => widget.dossier!;
bool get _isEnAttente =>
!_isCreate && _dossier.user.statut == 'en_attente';
static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : '';
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();
_nomCtrl = TextEditingController();
_prenomCtrl = TextEditingController();
_telCtrl = TextEditingController();
_emailCtrl = TextEditingController();
_adresseCtrl = TextEditingController();
_cpCtrl = TextEditingController();
_villeCtrl = TextEditingController();
_nirCtrl = TextEditingController();
_dateNaissanceCtrl = TextEditingController();
_lieuNaissanceVilleCtrl = TextEditingController();
_lieuNaissancePaysCtrl = TextEditingController(text: 'France');
_agrementCtrl = TextEditingController();
_dateAgrementCtrl = TextEditingController();
_capaciteCtrl = TextEditingController(text: '1');
_placesCtrl = TextEditingController(text: '1');
_presentationCtrl = TextEditingController();
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
@override
void dispose() {
_nomCtrl.dispose();
_prenomCtrl.dispose();
_telCtrl.dispose();
_emailCtrl.dispose();
_adresseCtrl.dispose();
_cpCtrl.dispose();
_villeCtrl.dispose();
_nirCtrl.dispose();
_dateNaissanceCtrl.dispose();
_lieuNaissanceVilleCtrl.dispose();
_lieuNaissancePaysCtrl.dispose();
_agrementCtrl.dispose();
_dateAgrementCtrl.dispose();
_capaciteCtrl.dispose();
_placesCtrl.dispose();
_presentationCtrl.dispose();
super.dispose();
}
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
List<AdminDetailField> _photoProFields(DossierAM d) {
final u = d.user;
return [
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField(
label: 'Date de naissance',
value: formatIsoDateFr(u.dateNaissance),
),
AdminDetailField(
label: 'Ville de naissance',
value: _v(u.lieuNaissanceVille),
),
AdminDetailField(
label: 'Pays de naissance',
value: _v(u.lieuNaissancePays),
),
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField(
label: 'Date dagrément',
value: formatIsoDateFr(d.dateAgrement),
),
AdminDetailField(
label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
),
AdminDetailField(
label: 'Places disponibles',
value: d.placesDisponibles != null
? d.placesDisponibles.toString()
: '',
),
];
}
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
Widget _buildPhotoSectionReview(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: LayoutBuilder(
builder: (context, c) {
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),
),
],
),
)
: AuthNetworkImage(
url: 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),
),
],
),
),
),
),
),
),
),
);
},
),
),
),
],
);
}
Future<void> _pickPhoto() 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) return;
setState(() {
_photoBytes = bytes;
_photoFilename = picked.name;
});
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Impossible de charger la photo'),
backgroundColor: Colors.red.shade700,
),
);
}
}
void _clearPhoto() => setState(() {
_photoBytes = null;
_photoFilename = null;
});
String? _validateStep0() {
final nom = _nomCtrl.text.trim();
final prenom = _prenomCtrl.text.trim();
if (nom.length < 2) return 'Le nom doit contenir au moins 2 caractères.';
if (prenom.length < 2) {
return 'Le prénom doit contenir au moins 2 caractères.';
}
final phoneErr = validateFrenchNationalPhone(_telCtrl.text);
if (phoneErr != null) return phoneErr;
final emailErr = validateEmail(_emailCtrl.text);
if (emailErr != null) return emailErr;
if (_adresseCtrl.text.trim().isEmpty) {
return 'Ladresse est requise.';
}
final cpErr = validateFrenchPostalCode(_cpCtrl.text);
if (cpErr != null) return cpErr;
if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.';
return null;
}
String? _validateStep1() {
if (_photoBytes == null || _photoBytes!.isEmpty) {
return 'Une photo de profil est requise.';
}
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
if (birthIso == null) {
return 'La date de naissance est invalide (jj/mm/aaaa).';
}
final ville = _lieuNaissanceVilleCtrl.text.trim();
final pays = _lieuNaissancePaysCtrl.text.trim();
if (ville.length < 2 || pays.length < 2) {
return 'La ville et le pays de naissance sont requis (au moins 2 caractères).';
}
final nirErr = validateNir(_nirCtrl.text);
if (nirErr != null) return nirErr;
if (_agrementCtrl.text.trim().isEmpty) {
return 'Le numéro dagrément est requis.';
}
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
if (agrementIso == null) {
return 'La date dagrément est invalide (jj/mm/aaaa).';
}
final capa = int.tryParse(_capaciteCtrl.text.trim());
if (capa == null || capa < 1 || capa > kAmCapaciteAccueilMax) {
return 'La capacité doit être entre 1 et $kAmCapaciteAccueilMax.';
}
final places = int.tryParse(_placesCtrl.text.trim());
if (places == null || places < 0 || places > capa) {
return 'Les places disponibles doivent être entre 0 et la capacité.';
}
return null;
}
String? _validateCurrentStep() {
if (!_isCreate) return null;
switch (_step) {
case 0:
return _validateStep0();
case 1:
return _validateStep1();
default:
return null;
}
}
void _goNext() {
final err = _validateCurrentStep();
if (err != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err), backgroundColor: Colors.red.shade700),
);
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<String, dynamic> _buildCreateBody() {
final bytes = _photoBytes!;
final mime = _imageMimeForBytes(bytes);
final photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
final fn = (_photoFilename ?? '').trim();
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text)!;
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text)!;
final capa = int.parse(_capaciteCtrl.text.trim());
final places = int.parse(_placesCtrl.text.trim());
final presentation = _presentationCtrl.text.trim();
return <String, dynamic>{
'email': normalizeEmailText(_emailCtrl.text),
'prenom': formatPersonNameCase(_prenomCtrl.text),
'nom': formatPersonNameCase(_nomCtrl.text),
'telephone': normalizePhone(_telCtrl.text),
'adresse':
_adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null,
'code_postal':
_cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null,
'ville': _villeCtrl.text.trim().isNotEmpty
? formatPersonNameCase(_villeCtrl.text)
: null,
'photo_base64': photoBase64,
'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg',
'consentement_photo': true,
'date_naissance': birthIso,
'lieu_naissance_ville':
formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
'lieu_naissance_pays':
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
'nir': normalizeNir(_nirCtrl.text),
'numero_agrement': _agrementCtrl.text.trim(),
'date_agrement': agrementIso,
'capacite_accueil': capa,
'places_disponibles': places,
'biographie': presentation.isNotEmpty ? presentation : null,
'acceptation_cgu': true,
'acceptation_privacy': true,
};
}
Future<void> _createAndValidate() async {
if (_submitting) return;
final err0 = _validateStep0();
if (err0 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700),
);
return;
}
final err1 = _validateStep1();
if (err1 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
);
return;
}
final ok = await showValidationValiderConfirmDialog(
context,
body:
'Créer et activer le dossier de cette assistante maternelle ? Un e-mail de création de mot de passe sera envoyé.',
);
if (!mounted || !ok) return;
setState(() => _submitting = true);
try {
await UserService.createAmDossier(_buildCreateBody());
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Dossier créé et validé. Lassistante maternelle est active.',
),
duration: Duration(seconds: 4),
),
);
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);
}
}
@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 _buildStep2();
default:
return const SizedBox();
}
}
Widget _buildStep0() {
if (_isCreate) {
return IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
);
}
final u = _dossier.user;
return IdentityBlock.readOnlyFromUser(
u,
title: 'Identité et coordonnées',
emptyLabel: '',
);
}
Widget _buildStep1() {
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile
// (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus).
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH)
.clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
final form = _isCreate
? _buildCreateProFields()
: ValidationDetailSection(
title: 'Dossier professionnel',
fields: _photoProFields(_dossier),
rowLayout: _photoProRowLayout,
);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: photoW,
child: _isCreate
? AdminAmPhotoFrame(
imageBytes: _photoBytes,
onTap: _pickPhoto,
onClear: _photoBytes != null ? _clearPhoto : null,
emptyLabel: 'Ajouter une photo',
)
: _buildPhotoSectionReview(_dossier.user),
),
const SizedBox(width: _photoProGap),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: form,
),
),
],
);
},
);
}
Widget _buildCreateProFields() {
return ValidationFormGrid(
title: 'Dossier professionnel',
rowLayout: _photoProRowLayout,
fields: [
ValidationLabeledField(
label: 'NIR',
field: ValidationNirField(controller: _nirCtrl),
),
ValidationLabeledField(
label: 'Date de naissance',
field: ValidationEditableField(
controller: _dateNaissanceCtrl,
hintText: 'jj / mm / aaaa',
keyboardType: TextInputType.number,
inputFormatters: const [FrenchDateInputFormatter()],
),
),
ValidationLabeledField(
label: 'Ville de naissance',
field: ValidationEditableField(
controller: _lieuNaissanceVilleCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: 'Pays de naissance',
field: ValidationEditableField(
controller: _lieuNaissancePaysCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: 'N° Agrément',
field: ValidationEditableField(controller: _agrementCtrl),
),
ValidationLabeledField(
label: 'Date dagrément',
field: ValidationEditableField(
controller: _dateAgrementCtrl,
hintText: 'jj / mm / aaaa',
keyboardType: TextInputType.number,
inputFormatters: const [FrenchDateInputFormatter()],
),
),
ValidationLabeledField(
label: 'Capacité max (enfants)',
field: ValidationEditableField(
controller: _capaciteCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
ValidationLabeledField(
label: 'Places disponibles',
field: ValidationEditableField(
controller: _placesCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
],
);
}
Widget _buildStep2() {
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 presentation =
(_dossier.presentation != null && _dossier.presentation!.trim().isNotEmpty)
? _dossier.presentation!
: '';
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(
presentation,
style: const TextStyle(color: Colors.black87, fontSize: 14),
),
),
),
);
},
),
),
],
);
}
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 (_isCreate) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
),
] else 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: _goNext,
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(_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(
isSubmitting: _submitting,
onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) {
if (comment == null || comment.trim().isEmpty) return;
_refuserEnvoyer(comment.trim());
},
),
),
],
),
);
}
Future<void> _refuserEnvoyer(String comment) async {
if (_submitting) return;
setState(() => _submitting = true);
try {
await UserService.refuseUser(_dossier.user.id, comment: comment);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
),
duration: Duration(seconds: 4),
),
);
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);
}
}
}