Files
petitspas/frontend/lib/widgets/dashboard/parent_dossier_wizard.dart
T
jmartinandCursor b474842e19 refactor(#155): phase 2 — panels staff sous widgets/dashboard/.
Déplace les panels/wizards/validation/common partagés hors de
widgets/admin/ ; ne conserve que AdminManagementWidget (variante A).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:23 +02:00

2032 lines
67 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/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:image_picker/image_picker.dart';
import 'package:p_tits_pas/models/dossier_unifie.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/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/name_format_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/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart';
import 'package:p_tits_pas/widgets/dashboard/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 ParentDossierWizardMode { review, create, edit }
/// Enfant en cours de saisie (création / édition). Contrôleurs propres, à disposer.
class _CreateChild {
/// Id existant en base (mode edit) — null = nouvel enfant → POST.
String? existingChildId;
String? existingPhotoUrl;
/// Statut dorigine (hors à naître) pour ne pas écraser garde/scolarise.
String? existingStatus;
final TextEditingController prenomCtrl = TextEditingController();
final TextEditingController nomCtrl = TextEditingController();
final TextEditingController dateCtrl = TextEditingController();
String? genre;
bool isUnborn = false;
Uint8List? photoBytes;
String? photoFilename;
bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty;
void dispose() {
prenomCtrl.dispose();
nomCtrl.dispose();
dateCtrl.dispose();
}
}
/// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135).
class ParentDossierWizard extends StatefulWidget {
final ParentDossierWizardMode mode;
final DossierFamille? dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const ParentDossierWizard._({
super.key,
required this.mode,
this.dossier,
required this.onClose,
required this.onSuccess,
this.onStepChanged,
});
factory ParentDossierWizard.review({
Key? key,
required DossierFamille dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return ParentDossierWizard._(
key: key,
mode: ParentDossierWizardMode.review,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
factory ParentDossierWizard.create({
Key? key,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return ParentDossierWizard._(
key: key,
mode: ParentDossierWizardMode.create,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
factory ParentDossierWizard.edit({
Key? key,
required DossierFamille dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return ParentDossierWizard._(
key: key,
mode: ParentDossierWizardMode.edit,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == ParentDossierWizardMode.create;
bool get isEdit => mode == ParentDossierWizardMode.edit;
/// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch.
static double get shellBodyHeight =>
ValidationFormMetrics.shellBodyHeightForRows(4) + 20;
@override
State<ParentDossierWizard> createState() => _ParentDossierWizardState();
}
class _ParentDossierWizardState extends State<ParentDossierWizard> {
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;
static const int _stepCount = 4;
// --- Mode création : parent principal ---
late final TextEditingController _p1NomCtrl;
late final TextEditingController _p1PrenomCtrl;
late final TextEditingController _p1TelCtrl;
late final TextEditingController _p1EmailCtrl;
late final TextEditingController _p1AdresseCtrl;
late final TextEditingController _p1CpCtrl;
late final TextEditingController _p1VilleCtrl;
// --- Mode création : co-parent (optionnel) ---
bool _hasCoParent = false;
bool _sameAddress = false;
late final TextEditingController _p2NomCtrl;
late final TextEditingController _p2PrenomCtrl;
late final TextEditingController _p2TelCtrl;
late final TextEditingController _p2EmailCtrl;
late final TextEditingController _p2AdresseCtrl;
late final TextEditingController _p2CpCtrl;
late final TextEditingController _p2VilleCtrl;
// --- Mode création : enfants + présentation ---
final List<_CreateChild> _children = [];
late final TextEditingController _presentationCtrl;
/// Nb de parents déjà en base au moment de louverture (mode edit).
int _initialParentCount = 0;
/// Enfants existants retirés en edit → DELETE au save.
final List<String> _removedEnfantIds = [];
bool get _isCreate => widget.isCreate;
bool get _isEdit => widget.isEdit;
bool get _isEditable => _isCreate || _isEdit;
DossierFamille get _dossier => widget.dossier!;
bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente;
String? get _firstParentId {
if (_isCreate) return null;
return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null;
}
/// Co-parent déjà présent au chargement (edit) — pas un ajout via POST.
bool get _hadExistingCoParent => _isEdit && _initialParentCount >= 2;
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) =>
formatIsoDateFr(s, ifEmpty: 'Non défini');
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
@override
void initState() {
super.initState();
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
_p1NomCtrl = TextEditingController();
_p1PrenomCtrl = TextEditingController();
_p1TelCtrl = TextEditingController();
_p1EmailCtrl = TextEditingController();
_p1AdresseCtrl = TextEditingController();
_p1CpCtrl = TextEditingController();
_p1VilleCtrl = TextEditingController();
_p2NomCtrl = TextEditingController();
_p2PrenomCtrl = TextEditingController();
_p2TelCtrl = TextEditingController();
_p2EmailCtrl = TextEditingController();
_p2AdresseCtrl = TextEditingController();
_p2CpCtrl = TextEditingController();
_p2VilleCtrl = TextEditingController();
_presentationCtrl = TextEditingController();
if (_isCreate) {
_children.add(_CreateChild());
} else if (_isEdit) {
_prefillFromDossier();
}
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
void _prefillFromDossier() {
final parents = _dossier.parents;
_initialParentCount = parents.length;
if (parents.isNotEmpty) {
final p1 = parents.first;
_p1NomCtrl.text = (p1.nom ?? '').trim();
_p1PrenomCtrl.text = (p1.prenom ?? '').trim();
_p1TelCtrl.text = (p1.telephone ?? '').trim();
_p1EmailCtrl.text = (p1.email).trim();
_p1AdresseCtrl.text = (p1.adresse ?? '').trim();
_p1CpCtrl.text = (p1.codePostal ?? '').trim();
_p1VilleCtrl.text = (p1.ville ?? '').trim();
if (parents.length >= 2) {
_hasCoParent = true;
final p2 = parents[1];
_p2NomCtrl.text = (p2.nom ?? '').trim();
_p2PrenomCtrl.text = (p2.prenom ?? '').trim();
_p2TelCtrl.text = (p2.telephone ?? '').trim();
_p2EmailCtrl.text = (p2.email).trim();
_p2AdresseCtrl.text = (p2.adresse ?? '').trim();
_p2CpCtrl.text = (p2.codePostal ?? '').trim();
_p2VilleCtrl.text = (p2.ville ?? '').trim();
} else {
_hasCoParent = false;
}
}
_prefillChildrenFromDossier();
}
void _prefillChildrenFromDossier() {
for (final c in _children) {
c.dispose();
}
_children.clear();
_removedEnfantIds.clear();
final enfants = _dossier.enfants;
if (enfants.isEmpty) {
_children.add(_CreateChild());
return;
}
for (final e in enfants) {
final status = (e.status ?? '').trim().toLowerCase();
final id = e.id.trim();
final child = _CreateChild()
..existingChildId = id.isEmpty ? null : id
..existingPhotoUrl = e.photoUrl
..existingStatus = status.isEmpty ? null : status
..isUnborn = status == 'a_naitre';
child.prenomCtrl.text = (e.firstName ?? '').trim();
child.nomCtrl.text = (e.lastName ?? '').trim();
final g = (e.gender ?? '').trim();
final gUp = g.toUpperCase();
if (gUp == 'H' || gUp == 'F') {
child.genre = gUp;
} else if (g == 'Autre' || child.isUnborn) {
child.genre = child.isUnborn ? 'Autre' : g;
}
final dateSrc = child.isUnborn ? e.dueDate : e.birthDate;
child.dateCtrl.text = formatIsoDateFrInput(dateSrc);
_children.add(child);
}
}
@override
void dispose() {
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
_enfantsScrollController.dispose();
_p1NomCtrl.dispose();
_p1PrenomCtrl.dispose();
_p1TelCtrl.dispose();
_p1EmailCtrl.dispose();
_p1AdresseCtrl.dispose();
_p1CpCtrl.dispose();
_p1VilleCtrl.dispose();
_p2NomCtrl.dispose();
_p2PrenomCtrl.dispose();
_p2TelCtrl.dispose();
_p2EmailCtrl.dispose();
_p2AdresseCtrl.dispose();
_p2CpCtrl.dispose();
_p2VilleCtrl.dispose();
for (final c in _children) {
c.dispose();
}
_presentationCtrl.dispose();
super.dispose();
}
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red.shade700),
);
}
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;
});
}
}
@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 _buildEnfantsStep();
case 3:
return _buildPresentationStep();
default:
return const SizedBox();
}
}
Widget _buildStep0() {
if (_isEditable) {
return IdentityBlock.editable(
title: 'Parent principal',
nomController: _p1NomCtrl,
prenomController: _p1PrenomCtrl,
telephoneController: _p1TelCtrl,
emailController: _p1EmailCtrl,
adresseController: _p1AdresseCtrl,
codePostalController: _p1CpCtrl,
villeController: _p1VilleCtrl,
);
}
return IdentityBlock.readOnlyFromParentDossier(
_dossier.parents.first,
title: 'Parent principal',
);
}
Widget _buildStep1() {
if (_isEditable) {
return _buildCoParentStepEditable(
allowToggle: !_hadExistingCoParent,
);
}
return _buildParent2Step();
}
Widget _buildCoParentStepEditable({required bool allowToggle}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Text(
'Deuxième parent',
style: TextStyle(
fontSize: ValidationFormMetrics.sectionTitleFontSize,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
if (allowToggle) ...[
const Spacer(),
const Text(
'Ajouter un co-parent',
style: TextStyle(fontSize: 14, color: Colors.black87),
),
Transform.scale(
scale: 0.75,
child: Switch(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: _hasCoParent,
onChanged: (v) => setState(() => _hasCoParent = v),
),
),
],
],
),
const SizedBox(height: 8),
if (!_hasCoParent)
const Text(
'Un seul parent pour ce dossier.',
style: TextStyle(color: Colors.black87),
)
else
ValidationFormGrid(
compact: true,
rowLayout: IdentityBlock.rowLayout,
rowFlex: IdentityBlock.rowFlex,
fields: [
ValidationLabeledField(
label: 'Nom',
field: ValidationEditableField(
controller: _p2NomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: 'Prénom',
field: ValidationEditableField(
controller: _p2PrenomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: 'Téléphone',
field: ValidationPhoneField(controller: _p2TelCtrl),
),
ValidationLabeledField(
label: 'Email',
field: ValidationEmailField(controller: _p2EmailCtrl),
),
ValidationLabeledField(
label: 'Adresse (N° et Rue)',
labelTrailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Même adresse',
style: TextStyle(
fontSize: ValidationFormMetrics.fieldLabelFontSize,
color: Colors.black87,
),
),
Transform.scale(
scale: 0.75,
child: Switch(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: _sameAddress,
onChanged: (v) => setState(() {
_sameAddress = v;
if (v) _copyP1AddressToP2();
}),
),
),
],
),
field: ValidationEditableField(
controller: _p2AdresseCtrl,
enabled: !_sameAddress,
),
),
ValidationLabeledField(
label: 'Code postal',
field: ValidationPostalCodeField(
controller: _p2CpCtrl,
enabled: !_sameAddress,
),
),
ValidationLabeledField(
label: 'Ville',
field: ValidationEditableField(
controller: _p2VilleCtrl,
enabled: !_sameAddress,
inputFormatters: const [PersonNameInputFormatter()],
),
),
],
),
],
);
}
void _copyP1AddressToP2() {
_p2AdresseCtrl.text = _p1AdresseCtrl.text;
_p2CpCtrl.text = _p1CpCtrl.text;
_p2VilleCtrl.text = _p1VilleCtrl.text;
}
Widget _buildParent2Step() {
if (_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 IdentityBlock.readOnlyFromParentDossier(
_dossier.parents[1],
title: 'Deuxième parent',
);
}
static const double _idPhotoAspectRatio = 35 / 45;
/// Liste horizontale scrollable (fondu bords) partagée par les cartes enfant
/// en lecture seule (review) et éditables (create).
Widget _buildEnfantsHorizontalList({
required int itemCount,
required Widget Function(int index) itemBuilder,
}) {
return 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<ScrollMetricsNotification>(
onNotification: (_) {
_syncEnfantsScrollFades();
return false;
},
child: ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (Rect bounds) {
final stops = <double>[
0.0,
_enfantsFadeExtent,
1.0 - _enfantsFadeExtent,
1.0,
];
if (!_enfantsIsScrollable) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: const <Color>[
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: <Color>[
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: itemCount,
itemBuilder: (_, i) => Padding(
padding: EdgeInsets.only(right: i < itemCount - 1 ? 16 : 0),
child: SizedBox(
width: cardWidth,
height: cardHeight,
child: itemBuilder(i),
),
),
),
),
),
);
},
);
}
Widget _buildEnfantsStep() {
if (_isCreate || _isEdit) {
return _buildEnfantsStepCreate();
}
final enfants = _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: _buildEnfantsHorizontalList(
itemCount: enfants.length,
itemBuilder: (i) => _buildEnfantCard(enfants[i]),
),
),
],
);
}
Widget _buildEnfantsStepCreate() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Text(
'Enfants',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const Spacer(),
TextButton.icon(
onPressed: _addChild,
icon: const Icon(Icons.add),
label: const Text('Ajouter un enfant'),
),
],
),
const SizedBox(height: 8),
Expanded(
child: _buildEnfantsHorizontalList(
itemCount: _children.length,
itemBuilder: (i) => _buildCreateEnfantCard(i),
),
),
],
);
}
void _addChild() {
setState(() => _children.add(_CreateChild()));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_enfantsScrollController.hasClients) return;
_enfantsScrollController.animateTo(
_enfantsScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
void _removeChild(int index) {
if (_children.length <= 1) return;
setState(() {
final removed = _children.removeAt(index);
final existingId = (removed.existingChildId ?? '').trim();
if (existingId.isNotEmpty) {
_removedEnfantIds.add(existingId);
}
removed.dispose();
});
}
Future<void> _pickChildPhoto(int index) 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 || index >= _children.length) return;
setState(() {
_children[index].photoBytes = bytes;
_children[index].photoFilename = picked.name;
});
} catch (_) {
if (!mounted) return;
_showError('Impossible de charger la photo.');
}
}
void _clearChildPhoto(int index) {
if (index >= _children.length) return;
setState(() {
_children[index].photoBytes = null;
_children[index].photoFilename = null;
});
}
/// 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<BoxShadow> _enfantCardShadows() => [
BoxShadow(
color: Colors.black.withOpacity(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 éditable (création) : prénom pleine largeur, puis photo 1/3 + champs 2/3.
Widget _buildCreateEnfantCard(int index) {
final child = _children[index];
final canRemove = _children.length > 1;
return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius),
child: Container(
decoration: _enfantCardDecoration(child.genre),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
// Réserve langle haut-droit pour la croix de suppression.
padding: EdgeInsets.fromLTRB(12, 12, canRemove ? 36 : 14, 8),
child: ValidationLabeledField(
label: 'Prénom',
field: ValidationEditableField(
controller: child.prenomCtrl,
hintText: child.isUnborn ? 'Facultatif' : null,
inputFormatters: const [PersonNameInputFormatter()],
),
),
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 1,
child: LayoutBuilder(
builder: (context, c) {
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: SizedBox(
width: pw,
height: ph,
child: AmPhotoFrame(
photoUrl: child.photoBytes == null
? child.existingPhotoUrl
: null,
imageBytes: child.photoBytes,
onTap: () => _pickChildPhoto(index),
onClear: child.photoBytes != null
? () => _clearChildPhoto(index)
: null,
emptyLabel: 'Ajouter une photo',
),
),
),
);
},
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 14, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ValidationLabeledField(
label: 'Nom',
field: ValidationEditableField(
controller: child.nomCtrl,
inputFormatters: const [
PersonNameInputFormatter(),
],
),
),
const SizedBox(height: 6),
ValidationLabeledField(
label: child.isUnborn
? 'Date prévisionnelle'
: 'Date de naissance',
labelTrailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'À naître',
style: TextStyle(
fontSize: ValidationFormMetrics
.fieldLabelFontSize,
color: Colors.black87,
),
),
Transform.scale(
scale: 0.75,
child: Switch(
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
value: child.isUnborn,
onChanged: (v) => setState(() {
child.isUnborn = v;
if (!v && child.genre == 'Autre') {
child.genre = null;
}
}),
),
),
],
),
field: ValidationEditableField(
controller: child.dateCtrl,
hintText: 'jj / mm / aaaa',
keyboardType: TextInputType.number,
inputFormatters: const [
FrenchDateInputFormatter(),
],
),
),
const SizedBox(height: 6),
ValidationLabeledField(
label: 'Genre',
field: _genreDropdown(index),
),
],
),
),
),
],
),
),
],
),
if (canRemove)
Positioned(
top: 6,
right: 6,
child: Material(
color: Colors.white,
elevation: 1,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _removeChild(index),
child: Tooltip(
message: 'Retirer cet enfant',
child: SizedBox(
width: 22,
height: 22,
child: Icon(
Icons.close,
size: 14,
color: Colors.grey.shade800,
),
),
),
),
),
),
],
),
),
);
}
Widget _genreDropdown(int index) {
final child = _children[index];
final items = <DropdownMenuItem<String>>[
const DropdownMenuItem(value: 'H', child: Text('Garçon')),
const DropdownMenuItem(value: 'F', child: Text('Fille')),
if (child.isUnborn)
const DropdownMenuItem(value: 'Autre', child: Text('Inconnu')),
];
final value = child.genre != null &&
items.any((e) => e.value == child.genre)
? child.genre
: null;
return SizedBox(
height: ValidationFormMetrics.fieldHeight,
child: DropdownButtonFormField<String>(
key: ValueKey('genre-$index-${child.isUnborn}'),
value: value,
isExpanded: true,
hint: Text(
'Sélectionner',
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
),
decoration: ValidationFieldDecoration.input(),
items: items,
onChanged: (v) => setState(() => child.genre = v),
),
);
}
/// 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,
),
),
),
),
],
),
),
),
],
),
),
],
),
),
);
}
/// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
String? _enfantColumnStatusLabel(EnfantDossier e) {
return enfantColumnStatusLabel(status: e.status, gender: e.gender);
}
/// 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) {
const photoRadius = 8.0;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(photoRadius),
border: Border.all(color: Colors.black.withOpacity(0.08)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(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),
),
)
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
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() {
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 p = _dossier.presentation ?? '';
final text = p.trim().isEmpty ? 'Non défini' : p;
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(
text,
style:
const TextStyle(color: Colors.black87, fontSize: 14),
),
),
),
);
},
),
),
],
);
}
// --- Validation (mode création) ---
String? _validateP1() {
final nom = _p1NomCtrl.text.trim();
final prenom = _p1PrenomCtrl.text.trim();
if (nom.length < 2) {
return 'Le nom du parent principal doit contenir au moins 2 caractères.';
}
if (prenom.length < 2) {
return 'Le prénom du parent principal doit contenir au moins 2 caractères.';
}
final phoneErr = validateFrenchNationalPhone(_p1TelCtrl.text);
if (phoneErr != null) return phoneErr;
final emailErr = validateEmail(_p1EmailCtrl.text);
if (emailErr != null) return emailErr;
if (_p1AdresseCtrl.text.trim().isEmpty) {
return 'Ladresse du parent principal est requise.';
}
final cpErr = validateFrenchPostalCode(_p1CpCtrl.text);
if (cpErr != null) return cpErr;
if (_p1VilleCtrl.text.trim().isEmpty) {
return 'La ville du parent principal est requise.';
}
return null;
}
String? _validateP2() {
if (!_hasCoParent) return null;
final nom = _p2NomCtrl.text.trim();
final prenom = _p2PrenomCtrl.text.trim();
if (nom.length < 2) {
return 'Le nom du co-parent doit contenir au moins 2 caractères.';
}
if (prenom.length < 2) {
return 'Le prénom du co-parent doit contenir au moins 2 caractères.';
}
final phoneErr = validateFrenchNationalPhone(_p2TelCtrl.text);
if (phoneErr != null) return 'Co-parent : $phoneErr';
final emailErr = validateEmail(_p2EmailCtrl.text);
if (emailErr != null) return 'Co-parent : $emailErr';
if (normalizeEmailText(_p2EmailCtrl.text) ==
normalizeEmailText(_p1EmailCtrl.text)) {
return 'Lemail du co-parent doit être différent de celui du parent principal.';
}
if (_sameAddress) {
_copyP1AddressToP2();
return null;
}
if (_p2AdresseCtrl.text.trim().isEmpty) {
return 'Ladresse du co-parent est requise.';
}
final cpErr = validateFrenchPostalCode(_p2CpCtrl.text);
if (cpErr != null) return 'Co-parent : $cpErr';
if (_p2VilleCtrl.text.trim().isEmpty) {
return 'La ville du co-parent est requise.';
}
return null;
}
String? _validateEnfants() {
if (_children.isEmpty) return 'Au moins un enfant est requis.';
for (var i = 0; i < _children.length; i++) {
final c = _children[i];
final label = 'Enfant ${i + 1}';
final prenom = c.prenomCtrl.text.trim();
if (c.isUnborn) {
if (prenom.isNotEmpty && prenom.length < 2) {
return '$label : le prénom doit contenir au moins 2 caractères.';
}
if (parseFrDateToIso(c.dateCtrl.text) == null) {
return '$label : indiquez une date prévisionnelle valide (jj/mm/aaaa).';
}
if (c.genre != 'H' && c.genre != 'F' && c.genre != 'Autre') {
return '$label : indiquez le genre (Garçon, Fille ou Inconnu).';
}
} else {
if (prenom.length < 2) {
return '$label : le prénom doit contenir au moins 2 caractères.';
}
if (parseFrDateToIso(c.dateCtrl.text) == null) {
return '$label : indiquez une date de naissance valide (jj/mm/aaaa).';
}
if (c.genre != 'H' && c.genre != 'F') {
return '$label : indiquez le genre (Garçon ou Fille).';
}
}
}
return null;
}
String? _validateCurrentStep() {
if (_isEdit) {
switch (_step) {
case 0:
return _validateP1();
case 1:
return _validateP2();
case 2:
return _validateEnfants();
default:
return null;
}
}
if (!_isCreate) return null;
switch (_step) {
case 0:
return _validateP1();
case 1:
return _validateP2();
case 2:
return _validateEnfants();
default:
return null;
}
}
void _goNext() {
final err = _validateCurrentStep();
if (err != null) {
_showError(err);
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> _childCreateToJson(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
final dateIso = parseFrDateToIso(c.dateCtrl.text);
final map = <String, dynamic>{
'genre': c.genre,
'consent_photo': true,
};
if (prenom.length >= 2) {
map['prenom'] = prenom;
}
if (nom.length >= 2) {
map['nom'] = nom;
}
if (c.isUnborn) {
if (dateIso != null) {
map['date_previsionnelle_naissance'] = dateIso;
}
} else if (dateIso != null) {
map['date_naissance'] = dateIso;
}
final bytes = c.photoBytes;
if (bytes != null && bytes.isNotEmpty) {
final mime = _imageMimeForBytes(bytes);
final fn = (c.photoFilename ?? '').trim();
map['photo_base64'] = 'data:$mime;base64,${base64Encode(bytes)}';
map['photo_filename'] = fn.isNotEmpty ? fn : 'enfant.jpg';
}
return map;
}
Map<String, dynamic> _buildCreateBody() {
final body = <String, dynamic>{
'email': normalizeEmailText(_p1EmailCtrl.text),
'prenom': formatPersonNameCase(_p1PrenomCtrl.text),
'nom': formatPersonNameCase(_p1NomCtrl.text),
'telephone': normalizePhone(_p1TelCtrl.text),
'adresse': _p1AdresseCtrl.text.trim(),
'code_postal': _p1CpCtrl.text.trim(),
'ville': formatPersonNameCase(_p1VilleCtrl.text),
'acceptation_cgu': true,
'acceptation_privacy': true,
};
if (_hasCoParent) {
if (_sameAddress) _copyP1AddressToP2();
body['co_parent_email'] = normalizeEmailText(_p2EmailCtrl.text);
body['co_parent_prenom'] = formatPersonNameCase(_p2PrenomCtrl.text);
body['co_parent_nom'] = formatPersonNameCase(_p2NomCtrl.text);
body['co_parent_telephone'] = normalizePhone(_p2TelCtrl.text);
body['co_parent_meme_adresse'] = _sameAddress;
if (!_sameAddress) {
body['co_parent_adresse'] = _p2AdresseCtrl.text.trim();
body['co_parent_code_postal'] = _p2CpCtrl.text.trim();
body['co_parent_ville'] = formatPersonNameCase(_p2VilleCtrl.text);
}
}
final presentation = _presentationCtrl.text.trim();
if (presentation.isNotEmpty) {
body['presentation_dossier'] = presentation;
}
body['enfants'] = _children.map(_childCreateToJson).toList();
return body;
}
Future<void> _createAndValidate() async {
if (_submitting) return;
final err0 = _validateP1();
if (err0 != null) {
_showError(err0);
return;
}
final err1 = _validateP2();
if (err1 != null) {
_showError(err1);
return;
}
final err2 = _validateEnfants();
if (err2 != null) {
_showError(err2);
return;
}
final ok = await showValidationValiderConfirmDialog(
context,
body:
'Créer et activer ce dossier famille ? Un e-mail de création de mot de passe sera envoyé à chaque parent créé.',
);
if (!mounted || !ok) return;
setState(() => _submitting = true);
try {
await UserService.createParentDossier(_buildCreateBody());
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Dossier créé et validé. La famille est active.'),
duration: Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
_showError(
e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur',
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Map<String, dynamic> _parentFicheBody({
required TextEditingController nom,
required TextEditingController prenom,
required TextEditingController email,
required TextEditingController tel,
required TextEditingController adresse,
required TextEditingController cp,
required TextEditingController ville,
}) {
return {
'nom': formatPersonNameCase(nom.text),
'prenom': formatPersonNameCase(prenom.text),
'email': normalizeEmailText(email.text),
'telephone': normalizePhone(tel.text),
'adresse': adresse.text.trim(),
'ville': formatPersonNameCase(ville.text),
'code_postal': cp.text.trim(),
};
}
Future<void> _saveEdit() async {
if (_submitting || !_isEdit) return;
final err0 = _validateP1();
if (err0 != null) {
_showError(err0);
return;
}
final err1 = _validateP2();
if (err1 != null) {
_showError(err1);
return;
}
final err2 = _validateEnfants();
if (err2 != null) {
_showError(err2);
return;
}
final pivotId = (_firstParentId ?? '').trim();
if (pivotId.isEmpty) {
_showError('Identifiant du parent principal manquant.');
return;
}
final addingCoParent = _hasCoParent && !_hadExistingCoParent;
final ok = await showValidationValiderConfirmDialog(
context,
body: addingCoParent
? 'Enregistrer le dossier et ajouter le co-parent ? Un e-mail de création de mot de passe lui sera envoyé.'
: 'Enregistrer les modifications du dossier famille ?',
);
if (!mounted || !ok) return;
setState(() => _submitting = true);
try {
await UserService.updateParentFiche(
parentUserId: pivotId,
body: _parentFicheBody(
nom: _p1NomCtrl,
prenom: _p1PrenomCtrl,
email: _p1EmailCtrl,
tel: _p1TelCtrl,
adresse: _p1AdresseCtrl,
cp: _p1CpCtrl,
ville: _p1VilleCtrl,
),
);
if (_hadExistingCoParent) {
final coId = _dossier.parents[1].id.trim();
if (coId.isEmpty) {
throw Exception('Identifiant du co-parent manquant.');
}
await UserService.updateParentFiche(
parentUserId: coId,
body: _parentFicheBody(
nom: _p2NomCtrl,
prenom: _p2PrenomCtrl,
email: _p2EmailCtrl,
tel: _p2TelCtrl,
adresse: _p2AdresseCtrl,
cp: _p2CpCtrl,
ville: _p2VilleCtrl,
),
);
} else if (addingCoParent) {
if (_sameAddress) _copyP1AddressToP2();
final body = <String, dynamic>{
'email': normalizeEmailText(_p2EmailCtrl.text),
'prenom': formatPersonNameCase(_p2PrenomCtrl.text),
'nom': formatPersonNameCase(_p2NomCtrl.text),
'telephone': normalizePhone(_p2TelCtrl.text),
'meme_adresse': _sameAddress,
};
if (!_sameAddress) {
body['adresse'] = _p2AdresseCtrl.text.trim();
body['code_postal'] = _p2CpCtrl.text.trim();
body['ville'] = formatPersonNameCase(_p2VilleCtrl.text);
}
await UserService.addCoParent(pivotId, body: body);
}
await _saveEnfantsEdit(pivotId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
addingCoParent
? 'Dossier enregistré. Co-parent ajouté.'
: 'Dossier enregistré.',
),
duration: const Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
_showError(
e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur',
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Map<String, dynamic> _enfantStaffBody(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
final dateIso = parseFrDateToIso(c.dateCtrl.text);
final existingStatus = (c.existingStatus ?? '').trim().toLowerCase();
final status = c.isUnborn
? 'a_naitre'
: (existingStatus.isNotEmpty && existingStatus != 'a_naitre'
? existingStatus
: 'sans_garde');
final gender = c.genre ?? 'H';
final map = <String, dynamic>{
'status': status,
'gender': gender,
'consent_photo': true,
};
if (prenom.length >= 2) map['first_name'] = prenom;
if (nom.length >= 2) map['last_name'] = nom;
if (c.isUnborn) {
if (dateIso != null) map['due_date'] = dateIso;
} else if (dateIso != null) {
map['birth_date'] = dateIso;
}
return map;
}
/// Empreinte identité pour rattacher un brouillon sans id à un enfant du GET.
String _enfantIdentityKey({
required String prenom,
required String nom,
required String? dateIso,
required bool isUnborn,
}) {
final pn = formatPersonNameCase(prenom).toLowerCase().trim();
final nm = formatPersonNameCase(nom).toLowerCase().trim();
final d = _normalizeDateKey(dateIso);
final kind = isUnborn ? 'due' : 'birth';
return '$pn|$nm|$kind|$d';
}
String _normalizeDateKey(String? raw) {
final s = (raw ?? '').trim();
if (s.isEmpty) return '';
final fromFr = parseFrDateToIso(s);
if (fromFr != null) return fromFr;
try {
final dt = DateTime.parse(s);
final y = dt.year.toString().padLeft(4, '0');
final m = dt.month.toString().padLeft(2, '0');
final d = dt.day.toString().padLeft(2, '0');
return '$y-$m-$d';
} catch (_) {
return s;
}
}
String _enfantKeyFromCreate(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
return _enfantIdentityKey(
prenom: prenom,
nom: nom,
dateIso: parseFrDateToIso(c.dateCtrl.text),
isUnborn: c.isUnborn,
);
}
String _enfantKeyFromDossier(EnfantDossier e) {
final isUnborn = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
final nomRaw = (e.lastName ?? '').trim();
final nom = nomRaw.isNotEmpty ? nomRaw : (_p1NomCtrl.text.trim());
return _enfantIdentityKey(
prenom: e.firstName ?? '',
nom: nom,
dateIso: isUnborn ? e.dueDate : e.birthDate,
isUnborn: isUnborn,
);
}
bool _isBlankChildDraft(_CreateChild c) {
if (c.hasExistingId) return false;
final prenom = c.prenomCtrl.text.trim();
final nom = c.nomCtrl.text.trim();
final date = c.dateCtrl.text.trim();
return prenom.isEmpty && nom.isEmpty && date.isEmpty && c.genre == null;
}
/// Résout lid enfant : tracker edit, sinon match sur le GET dossier.
String? _resolveExistingChildId(
_CreateChild c,
Map<String, String> dossierIdByIdentity,
Set<String> alreadyUsedIds,
) {
final tracked = (c.existingChildId ?? '').trim();
if (tracked.isNotEmpty && !alreadyUsedIds.contains(tracked)) {
return tracked;
}
final key = _enfantKeyFromCreate(c);
final matched = (dossierIdByIdentity[key] ?? '').trim();
if (matched.isNotEmpty && !alreadyUsedIds.contains(matched)) {
return matched;
}
return null;
}
Future<void> _saveEnfantsEdit(String pivotUserId) async {
// Index id connus du GET (ne jamais POST pour ceux-là).
final dossierIdByIdentity = <String, String>{};
for (final e in _dossier.enfants) {
final id = e.id.trim();
if (id.isEmpty) continue;
dossierIdByIdentity[_enfantKeyFromDossier(e)] = id;
}
for (final id in _removedEnfantIds) {
await UserService.deleteEnfant(id);
}
final usedIds = <String>{..._removedEnfantIds};
for (final c in _children) {
if (_isBlankChildDraft(c)) continue;
final existingId = _resolveExistingChildId(
c,
dossierIdByIdentity,
usedIds,
);
final body = _enfantStaffBody(c);
if (existingId != null && existingId.isNotEmpty) {
// Ré-attache lid au modèle (si match identité a récupéré un id perdu).
c.existingChildId = existingId;
final bytes = c.photoBytes;
await UserService.updateEnfant(
enfantId: existingId,
body: body,
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
photoFilename: c.photoFilename,
);
usedIds.add(existingId);
continue;
}
// Uniquement les vrais nouveaux enfants (pas did dossier).
final bytes = c.photoBytes;
final created = await UserService.createEnfant(
parentUserId: pivotUserId,
body: body,
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
photoFilename: c.photoFilename,
);
final newId = created.id.trim();
if (newId.isNotEmpty) {
c.existingChildId = newId;
usedIds.add(newId);
}
}
}
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 (_isCreate) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
),
] else if (_isEdit) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _saveEdit,
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
),
] else 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: _goNext,
child: const Text('Suivant'),
),
],
);
}
Future<void> _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<void> _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;
_showError(
e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur',
);
} 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());
},
),
),
],
),
);
}
/// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110.
Future<void> _refuserEnvoyer(String comment) async {
if (_submitting) return;
final parentId = _firstParentId?.trim();
if (parentId == null || parentId.isEmpty || !_isEnAttente) {
if (!mounted) return;
_showError('Aucun compte en attente à refuser pour ce dossier.');
return;
}
setState(() => _submitting = true);
try {
await UserService.refuseUser(parentId, comment: comment);
if (!mounted) return;
final nbEnAttente =
_dossier.parents.where((p) => p.statut == 'en_attente').length;
final msg = nbEnAttente > 1
? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.'
: 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: const Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
_showError(
e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur',
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
}