Squash depuis develop : POST /assistantes-maternelles/dossier (actif + mail MDP), AmDossierWizard create/review, validations inscription. Co-authored-by: Cursor <cursoragent@cursor.com>
966 lines
32 KiB
Dart
966 lines
32 KiB
Dart
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 d’agré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 'L’adresse 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 d’agrément est requis.';
|
||
}
|
||
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
|
||
if (agrementIso == null) {
|
||
return 'La date d’agré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é. L’assistante 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 d’agré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);
|
||
}
|
||
}
|
||
}
|