Files
petitspas/frontend/lib/widgets/admin/parent_dossier_wizard.dart
T
jmartinandCursor 14580c34e0 feat(#129): création dossier famille staff — API + wizard dashboard.
Permet au dashboard de créer un dossier parent complet (pivot, co-parent
optionnel, enfants, n° dossier) déjà actif, avec e-mail de création de
mot de passe — miroir du parcours AM (#156), sans passer par l’inscription
publique en attente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:16:02 +02:00

1597 lines
53 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/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 }
/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer.
class _CreateChild {
final TextEditingController prenomCtrl = TextEditingController();
final TextEditingController nomCtrl = TextEditingController();
final TextEditingController dateCtrl = TextEditingController();
String? genre;
bool isUnborn = false;
Uint8List? photoBytes;
String? photoFilename;
void dispose() {
prenomCtrl.dispose();
nomCtrl.dispose();
dateCtrl.dispose();
}
}
/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107)
/// et [create] (création dossier famille actif par le staff, ticket #129).
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,
);
}
bool get isCreate => mode == ParentDossierWizardMode.create;
/// 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;
bool get _isCreate => widget.isCreate;
DossierFamille get _dossier => widget.dossier!;
bool get _isEnAttente => !_isCreate && _dossier.isEnAttente;
String? get _firstParentId {
if (_isCreate) return null;
return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null;
}
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());
}
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
@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 (_isCreate) {
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 (_isCreate) {
return _buildCoParentStepCreate();
}
return _buildParent2Step();
}
Widget _buildCoParentStepCreate() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Text(
'Deuxième parent',
style: TextStyle(
fontSize: ValidationFormMetrics.sectionTitleFontSize,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
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) {
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);
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(
padding: const EdgeInsets.fromLTRB(12, 12, 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(
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: 4,
right: 4,
child: Material(
color: Colors.transparent,
child: IconButton(
tooltip: 'Retirer cet enfant',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 32, minHeight: 32),
icon: Icon(Icons.close,
size: 18, color: Colors.grey.shade700),
onPressed: () => _removeChild(index),
),
),
),
],
),
),
);
}
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 (!_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);
}
}
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 (_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);
}
}
}