Wizard edit famille/AM, POST co-parent, PATCH enfants avec photo. Co-authored-by: Cursor <cursoragent@cursor.com>
2034 lines
67 KiB
Dart
2034 lines
67 KiB
Dart
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/admin/common/admin_am_photo_frame.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 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 d’origine (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 l’ouverture (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 l’angle 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: AdminAmPhotoFrame(
|
||
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 'L’adresse 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 'L’email du co-parent doit être différent de celui du parent principal.';
|
||
}
|
||
if (_sameAddress) {
|
||
_copyP1AddressToP2();
|
||
return null;
|
||
}
|
||
if (_p2AdresseCtrl.text.trim().isEmpty) {
|
||
return 'L’adresse 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,
|
||
'grossesse_multiple': false,
|
||
};
|
||
|
||
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,
|
||
'is_multiple': false,
|
||
};
|
||
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 l’id 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 l’id 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 d’id 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);
|
||
}
|
||
}
|
||
}
|