petitspas/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart
Julien Martin 04f49cb62f feat(#132): création enfant staff (onglet Enfants) — front + back.
Squash depuis develop : POST /enfants staff + parent_user_id, photo
multipart, modale création + sélection famille, fixes UX/birth_date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:00:12 +02:00

1534 lines
46 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 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/auth_service.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/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Fiche enfant consultation / édition (#138) ou création (#132).
class AdminChildDetailModal extends StatefulWidget {
final EnfantAdminModel? enfant;
final VoidCallback? onSaved;
final VoidCallback? onDeleted;
final bool isCreating;
const AdminChildDetailModal({
super.key,
required EnfantAdminModel this.enfant,
this.onSaved,
this.onDeleted,
}) : isCreating = false;
/// Création depuis l'onglet Enfants (#132).
const AdminChildDetailModal.create({
super.key,
this.onSaved,
}) : enfant = null,
onDeleted = null,
isCreating = true;
@override
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
}
class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
late final TextEditingController _prenomCtrl;
late final TextEditingController _nomCtrl;
late final TextEditingController _birthCtrl;
late final TextEditingController _dueCtrl;
late String _status;
late String? _gender;
late bool _consentPhoto;
late bool _isMultiple;
bool _dirty = false;
bool _saving = false;
bool _deleting = false;
bool _loadingAm = true;
String? _currentUserRole;
AssistanteMaternelleModel? _linkedAm;
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
String? _baselineAmUserId;
/// Famille choisie en mode création (#132).
AdminFamilleFoyer? _selectedFamily;
/// Photo locale (création) — upload multipart `photo`.
Uint8List? _photoBytes;
String? _photoFilename;
static const double _modalWidth = 930;
static const double _mainRowHeight = 380;
static const double _placementHeight = 96;
static const double _fieldHeight = 44;
static const double _photoProGap = 24;
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
static const double _photoColumnMaxWidth = 280;
static const List<int> _fieldsRowLayout = [2, 2, 1];
bool get _isUnborn => _status == 'a_naitre';
bool get _isScolarise => _status == 'scolarise';
bool get _isSansGarde => _status == 'sans_garde';
bool get _showsAmPlacement => !_isScolarise;
List<String> get _availableGenders =>
_isUnborn ? const ['H', 'F', 'Autre'] : const ['H', 'F'];
TextEditingController get _activeDateCtrl =>
_isUnborn ? _dueCtrl : _birthCtrl;
String get _dateFieldLabel =>
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
bool get _canDelete =>
!widget.isCreating &&
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
bool get _busy => _saving || _deleting;
/// Création : tous les champs sauf photo / consentement ; édition : au moins une modif.
bool get _canSubmit {
if (_busy) return false;
if (widget.isCreating) return _isCreateFormComplete;
return _dirty;
}
bool get _isCreateFormComplete {
if (_selectedFamily == null) return false;
if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false;
if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false;
if (_dateToIso(_activeDateCtrl.text) == null) return false;
if (_gender == null || !_availableGenders.contains(_gender)) return false;
return true;
}
@override
void initState() {
super.initState();
final e = widget.enfant;
_prenomCtrl = TextEditingController(text: e?.firstName ?? '');
_nomCtrl = TextEditingController(text: e?.lastName ?? '');
_birthCtrl = TextEditingController(
text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''),
);
_dueCtrl = TextEditingController(
text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''),
);
_status = normalizeEnfantStatus(e?.status);
if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde';
}
if (widget.isCreating) {
_gender = null;
} else {
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
}
_consentPhoto = e?.consentPhoto ?? false;
_isMultiple = e?.isMultiple ?? false;
for (final c in [_prenomCtrl, _nomCtrl]) {
c.addListener(_onNameChanged);
}
for (final c in [_birthCtrl, _dueCtrl]) {
c.addListener(_onDateChanged);
}
_loadCurrentUserRole();
if (widget.isCreating) {
_loadingAm = false;
_dirty = true;
} else {
_prenomCtrl.addListener(_markDirty);
_nomCtrl.addListener(_markDirty);
_loadLinkedAm();
}
}
void _onNameChanged() => setState(() {});
void _onDateChanged() {
setState(() => _dirty = true);
}
/// Date prévisionnelle strictement avant aujourdhui (statut à naître).
bool get _dueDateInPast {
if (!_isUnborn) return false;
final iso = _dateToIso(_dueCtrl.text);
if (iso == null) return false;
try {
final due = DateTime.parse(iso);
final now = DateTime.now();
final dueDay = DateTime(due.year, due.month, due.day);
final today = DateTime(now.year, now.month, now.day);
return dueDay.isBefore(today);
} catch (_) {
return false;
}
}
Future<void> _loadLinkedAm() async {
if (widget.isCreating) {
setState(() => _loadingAm = false);
return;
}
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) {
setState(() => _loadingAm = false);
return;
}
setState(() => _loadingAm = true);
try {
final am = await UserService.findAmForEnfant(enfantId);
if (!mounted) return;
setState(() {
_linkedAm = am;
_baselineAmUserId = am?.user.id;
_loadingAm = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_baselineAmUserId = null;
_loadingAm = false;
});
}
}
Future<void> _loadCurrentUserRole() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() => _currentUserRole = cached.role);
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() => _currentUserRole = refreshed.role);
}
@override
void dispose() {
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
c.dispose();
}
super.dispose();
}
static String _normalizeGender(String? raw, {required bool allowUnknown}) {
final g = (raw ?? '').trim();
if (g == 'M') return 'H';
if (g == 'F' || g == 'H') return g;
if (g == 'Autre' && allowUnknown) return 'Autre';
return 'H';
}
void _coerceGenderForStatus() {
if (_gender != null && !_availableGenders.contains(_gender)) {
_gender = null;
}
}
void _markDirty() {
if (!_dirty) setState(() => _dirty = true);
}
String? _dateToIso(String text) => parseFrDateToIso(text);
String _headerTitle() {
if (widget.isCreating) {
final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) return 'Nouvel enfant';
return '$fn $ln'.trim();
}
final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) {
final fallback = widget.enfant?.fullName.trim() ?? '';
return fallback.isNotEmpty ? fallback : 'Enfant';
}
return '$fn $ln'.trim();
}
List<EnfantParentLink> get _parentLinks =>
(widget.enfant?.parentLinks ?? const <EnfantParentLink>[])
.where((l) => l.parentId.trim().isNotEmpty)
.toList();
Future<void> _openParent(EnfantParentLink link) async {
if (_busy) return;
final id = link.parentId.trim();
if (id.isEmpty) return;
try {
final parent = await UserService.getParent(id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminParentEditModal(
parent: parent,
onSaved: () => widget.onSaved?.call(),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Widget? _parentsSubtitle() {
if (widget.isCreating) {
final family = _selectedFamily;
if (family == null) return null;
return Text(
family.parentNames.isNotEmpty
? 'Famille : ${family.parentNames.join(', ')}'
: family.displayTitle,
style: const TextStyle(fontSize: 13, color: Colors.black54),
);
}
final links = _parentLinks;
if (links.isEmpty) return null;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text(
'Responsables : ',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
for (var i = 0; i < links.length; i++) ...[
if (i > 0)
const Text(
', ',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
_parentNameLink(links[i]),
],
],
);
}
Widget _parentNameLink(EnfantParentLink link) {
final name = (link.parentName ?? '').trim();
final label = name.isNotEmpty ? name : 'Parent rattaché';
return InkWell(
onTap: _busy ? null : () => _openParent(link),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: ValidationModalTheme.primaryActionBackground,
decoration: TextDecoration.underline,
decorationColor: ValidationModalTheme.primaryActionBackground
.withValues(alpha: 0.5),
),
),
),
);
}
Future<void> _save() async {
if (!_dirty) return;
if (widget.isCreating) {
await _saveCreate();
return;
}
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
setState(() => _saving = true);
try {
await _syncAmPlacement();
await UserService.updateEnfant(
enfantId: enfantId,
body: {
'first_name': formatPersonNameCase(_prenomCtrl.text),
'last_name': formatPersonNameCase(_nomCtrl.text),
'status': _status,
'gender': _gender,
if (_isUnborn)
if (_dateToIso(_dueCtrl.text) != null)
'due_date': _dateToIso(_dueCtrl.text)
else if (_dateToIso(_birthCtrl.text) != null)
'birth_date': _dateToIso(_birthCtrl.text),
'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
_baselineAmUserId = _linkedAm?.user.id;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche enfant enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _saveCreate() async {
final family = _selectedFamily;
if (family == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Choisissez une famille / un dossier avant d\'enregistrer'),
),
);
return;
}
final prenom = formatPersonNameCase(_prenomCtrl.text);
final nom = formatPersonNameCase(_nomCtrl.text);
if (prenom.isEmpty || nom.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Prénom et nom sont obligatoires')),
);
return;
}
if (!_isUnborn && _dateToIso(_birthCtrl.text) == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Date de naissance invalide ou manquante')),
);
return;
}
final hasPhoto = _photoBytes != null && _photoBytes!.isNotEmpty;
if (hasPhoto && !_consentPhoto) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Activez le consentement photo pour enregistrer une photo',
),
),
);
return;
}
setState(() => _saving = true);
try {
await UserService.createEnfant(
parentUserId: family.pivotParentUserId,
photoBytes: hasPhoto ? _photoBytes : null,
photoFilename: _photoFilename,
body: {
'first_name': prenom,
'last_name': nom,
'status': _status,
'gender': _gender,
if (_isUnborn) ...{
if (_dateToIso(_dueCtrl.text) != null)
'due_date': _dateToIso(_dueCtrl.text),
} else ...{
if (_dateToIso(_birthCtrl.text) != null)
'birth_date': _dateToIso(_birthCtrl.text),
},
'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
});
widget.onSaved?.call();
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant créé')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
Future<void> _syncAmPlacement() async {
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
final baselineId = _baselineAmUserId;
final currentId = _linkedAm?.user.id;
if (baselineId != null && baselineId != currentId) {
await UserService.detachEnfantFromAm(
amUserId: baselineId,
enfantId: enfantId,
);
}
if (currentId != null && currentId != baselineId) {
await UserService.attachEnfantToAm(
amUserId: currentId,
enfantId: enfantId,
);
}
}
Future<void> _delete() async {
if (!_canDelete || _deleting) return;
final name = _headerTitle();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Supprimer l\'enfant'),
content: Text(
'Supprimer définitivement la fiche de $name ?\n'
'Cette action est irréversible.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _deleting = true);
try {
final id = widget.enfant?.id;
if (id == null || id.isEmpty) return;
await UserService.deleteEnfant(id);
if (!mounted) return;
widget.onDeleted?.call();
widget.onSaved?.call();
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche enfant supprimée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _deleting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _openLinkedAm() async {
final am = _linkedAm;
if (am == null) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminAmEditModal(
assistante: am,
onSaved: () async {
await _reloadPlacementFromServer();
widget.onSaved?.call();
},
),
);
}
/// Recharge AM + statut après une modale externe (ex. fiche AM).
Future<void> _reloadPlacementFromServer() async {
final id = widget.enfant?.id;
if (id == null || id.isEmpty) return;
try {
final results = await Future.wait([
UserService.findAmForEnfant(id),
UserService.getEnfant(id),
]);
if (!mounted) return;
final am = results[0] as AssistanteMaternelleModel?;
final enfant = results[1] as EnfantAdminModel;
setState(() {
_linkedAm = am;
_baselineAmUserId = am?.user.id;
_status = normalizeEnfantStatus(enfant.status);
if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde';
}
_coerceGenderForStatus();
});
} catch (_) {
if (!mounted) return;
await _loadLinkedAm();
}
}
Future<void> _attachAm() async {
if (_linkedAm != null && !_isSansGarde) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Détachez l\'assistante actuelle avant d\'en choisir une autre',
),
),
);
return;
}
if (!mounted) return;
final selected = await AdminSelectAmModal.show(
context,
excludeIds: {
if (_linkedAm != null) _linkedAm!.user.id,
},
title: 'Choisir une assistante maternelle',
);
if (selected == null || !mounted) return;
setState(() {
_linkedAm = selected;
if (_status == 'sans_garde') {
_status = 'garde';
}
_dirty = true;
});
}
Future<void> _detachAm() async {
final am = _linkedAm;
if (am == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'assistante'),
content: Text(
'Retirer ${am.user.fullName} de la garde de cet enfant ?\n'
'(Effectif après Sauvegarder.)',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Détacher'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_linkedAm = null;
if (_status != 'a_naitre' && _status != 'scolarise') {
_status = 'sans_garde';
}
_dirty = true;
});
}
/// Passe en « Sans garde » : cadre vide local (détachement au Sauvegarder).
void _applySansGardeStatus() {
if (_linkedAm == null) return;
setState(() {
_linkedAm = null;
_dirty = true;
});
}
/// Conserve la date saisie en basculant naissance ↔ prévisionnelle.
void _preserveDateOnStatusChange(String from, String to) {
final becomingUnborn = to == 'a_naitre' && from != 'a_naitre';
final leavingUnborn = from == 'a_naitre' && to != 'a_naitre';
if (becomingUnborn) {
if (_dueCtrl.text.trim().isEmpty &&
_birthCtrl.text.trim().isNotEmpty) {
_dueCtrl.text = _birthCtrl.text;
}
} else if (leavingUnborn) {
if (_birthCtrl.text.trim().isEmpty &&
_dueCtrl.text.trim().isNotEmpty) {
_birthCtrl.text = _dueCtrl.text;
}
}
}
InputDecoration _inputDecoration({String? hint, bool error = false}) {
final base = ValidationFieldDecoration.input(hint: hint).copyWith(
isDense: false,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
);
if (!error) return base;
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.red.shade400),
);
return base.copyWith(
fillColor: Colors.red.shade50,
enabledBorder: border,
focusedBorder: border,
border: border,
);
}
Widget _dropdownField({
Key? key,
required String? value,
required List<MapEntry<String, String>> items,
required ValueChanged<String> onChanged,
String? hint,
}) {
final safeValue =
value != null && items.any((e) => e.key == value) ? value : null;
return SizedBox(
height: _fieldHeight,
child: DropdownButtonFormField<String>(
key: key,
value: safeValue,
hint: hint != null
? Text(hint, style: TextStyle(color: Colors.grey.shade600))
: null,
isExpanded: true,
decoration: _inputDecoration(),
items: items
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: _busy
? null
: (v) {
if (v != null) onChanged(v);
},
),
);
}
Widget _textField(
TextEditingController controller, {
String? hint,
Key? key,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
bool error = false,
Widget? suffixIcon,
}) {
return SizedBox(
height: _fieldHeight,
child: TextField(
key: key,
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
textAlignVertical: TextAlignVertical.center,
style: TextStyle(
color: error ? Colors.red.shade800 : Colors.black87,
fontSize: 14,
),
decoration: _inputDecoration(
hint: hint,
error: error,
).copyWith(suffixIcon: suffixIcon),
),
);
}
Widget _dateField() {
final past = _dueDateInPast;
return _textField(
_activeDateCtrl,
hint: 'jj / mm / aaaa',
key: ValueKey(_status),
keyboardType: TextInputType.number,
inputFormatters: const [FrenchDateInputFormatter()],
error: past,
suffixIcon: past
? Tooltip(
message:
'Date prévisionnelle antérieure à aujourdhui',
child: Icon(
Icons.info_outline,
size: 20,
color: Colors.red.shade700,
),
)
: null,
);
}
Widget _fieldsGrid() {
return ValidationEditableSection(
rowLayout: _fieldsRowLayout,
fields: [
ValidationLabeledField(
label: 'Prénom',
field: _textField(
_prenomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: 'Nom',
field: _textField(
_nomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: _dateFieldLabel,
field: _dateField(),
),
ValidationLabeledField(
label: 'Statut',
field: _dropdownField(
value: _status,
items: enfantStatusValues
.map(
(s) => MapEntry(
s,
enfantStatusLabel(s, gender: _gender),
),
)
.toList(),
onChanged: (v) {
setState(() {
_preserveDateOnStatusChange(_status, v);
_status = v;
_coerceGenderForStatus();
_dirty = true;
});
if (v == 'sans_garde') {
_applySansGardeStatus();
}
},
),
),
ValidationLabeledField(
label: 'Genre',
field: _dropdownField(
key: ValueKey('genre-$_status'),
value: _gender,
hint: 'Veuillez sélectionner le genre',
items: _availableGenders
.map((g) => MapEntry(g, _genderLabel(g)))
.toList(),
onChanged: (v) => setState(() {
_gender = v;
_dirty = true;
}),
),
),
],
);
}
Widget _consentPhotoSwitch() {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
title: const Text(
'Consentement photo',
style: TextStyle(fontSize: 13),
),
value: _consentPhoto,
onChanged: _busy
? null
: (v) => setState(() {
_consentPhoto = v;
_dirty = true;
}),
);
}
Future<void> _pickPhoto() async {
if (_busy || !widget.isCreating) return;
try {
final picked = await ImagePicker().pickImage(
source: ImageSource.gallery,
imageQuality: 75,
maxWidth: 1200,
maxHeight: 1200,
);
if (picked == null) return;
final bytes = await picked.readAsBytes();
if (bytes.isEmpty) return;
final name = picked.name.trim();
if (!mounted) return;
setState(() {
_photoBytes = bytes;
_photoFilename = name.isNotEmpty ? name : 'photo.jpg';
_consentPhoto = true;
_dirty = true;
});
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger la photo')),
);
}
}
void _clearPhoto() {
if (_busy || !widget.isCreating) return;
setState(() {
_photoBytes = null;
_photoFilename = null;
_dirty = true;
});
}
Widget _mainRow() {
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight;
final idealPhotoW =
maxRowH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth);
if (photoW > maxPhotoW) photoW = maxPhotoW;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: photoW,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: AdminAmPhotoFrame(
photoUrl: widget.isCreating
? null
: widget.enfant?.photoUrl,
imageBytes: widget.isCreating ? _photoBytes : null,
onTap: widget.isCreating && !_busy ? _pickPhoto : null,
onClear: widget.isCreating &&
!_busy &&
_photoBytes != null &&
_photoBytes!.isNotEmpty
? _clearPhoto
: null,
emptyLabel: widget.isCreating
? 'Cliquer pour ajouter'
: 'Aucune photo',
),
),
const SizedBox(height: 8),
_consentPhotoSwitch(),
],
),
),
const SizedBox(width: _photoProGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_fieldsGrid(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Align(
alignment: Alignment.bottomCenter,
child: _placementBlock(),
),
),
),
],
),
),
],
);
},
);
}
Widget _scolariseCard() {
return Container(
height: _placementHeight,
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFE8F4FD),
const Color(0xFFDCEEFB),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF90CAF9)),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.school_rounded,
size: 32,
color: Colors.blue.shade700,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
enfantStatusLabel('scolarise', gender: _gender),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.blue.shade900,
),
),
const SizedBox(height: 4),
Text(
'Enfant scolarisé — pas de garde chez une assistante maternelle',
style: TextStyle(
fontSize: 13,
color: Colors.blue.shade800.withValues(alpha: 0.85),
),
),
],
),
),
Icon(Icons.auto_stories_outlined, size: 28, color: Colors.blue.shade400),
],
),
);
}
Widget _emptyAmSlot() {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _busy ? null : _attachAm,
borderRadius: BorderRadius.circular(10),
child: Container(
height: _placementHeight,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Icon(
Icons.face_outlined,
size: 30,
color: Colors.grey.shade400,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aucune assistante maternelle',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 4),
Text(
'Cliquer pour choisir une assistante',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
Icon(Icons.add_circle_outline, color: Colors.grey.shade500),
],
),
),
),
);
}
Widget _linkedAmCard() {
final am = _linkedAm!;
final zone = (am.residenceCity ?? '').trim();
final agrement = (am.approvalNumber ?? '').trim();
final subtitle = [
if (zone.isNotEmpty) 'Zone : $zone',
if (agrement.isNotEmpty) 'Agrément : $agrement',
].join(' · ');
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _busy ? null : _openLinkedAm,
borderRadius: BorderRadius.circular(10),
child: Container(
height: _placementHeight,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF7F3FC),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFD4C4EF)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
_amAvatar(am.user.photoUrl),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
am.user.fullName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Color(0xFF4A2F7A),
),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
],
],
),
),
IconButton(
icon: const Icon(Icons.open_in_new, size: 20),
tooltip: 'Ouvrir la fiche AM',
onPressed: _busy ? null : _openLinkedAm,
),
IconButton(
icon: Icon(Icons.link_off, size: 20, color: Colors.orange.shade800),
tooltip: 'Détacher',
onPressed: _busy ? null : _detachAm,
),
],
),
),
),
);
}
Widget _amAvatar(String? photoUrl) {
const size = 56.0;
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
final url = ApiConfig.absoluteMediaUrl(photoUrl);
if (url.isEmpty) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.face, size: 30, color: iconColor),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: size,
height: size,
color: bg,
child: const Icon(Icons.face, size: 30, color: iconColor),
),
),
);
}
String get _placementTitle {
if (widget.isCreating) return 'Sélection du dossier de la famille';
return _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
}
Widget _placementBlock() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_placementTitle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 8),
if (widget.isCreating) _familyPlacementSection() else _placementSection(),
],
);
}
Future<void> _pickFamily() async {
if (_busy) return;
final selected = await AdminSelectFamilleModal.show(
context,
title: 'Choisir une famille',
);
if (selected == null || !mounted) return;
setState(() {
_selectedFamily = selected;
_dirty = true;
});
}
Widget _familyPlacementSection() {
final family = _selectedFamily;
if (family == null) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _busy ? null : _pickFamily,
borderRadius: BorderRadius.circular(10),
child: Container(
height: _placementHeight,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Icon(
Icons.family_restroom,
size: 30,
color: Colors.grey.shade400,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aucune famille sélectionnée',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 4),
Text(
'Cliquer pour choisir un dossier / foyer',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
Icon(Icons.add_circle_outline, color: Colors.grey.shade500),
],
),
),
),
);
}
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _busy ? null : _pickFamily,
borderRadius: BorderRadius.circular(10),
child: Container(
height: _placementHeight,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF8F5FC),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFD8CCE8)),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFFEDE5FA),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.family_restroom,
size: 30,
color: Color(0xFF6B3FA0),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
family.displayTitle,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (family.parentNames.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
family.parentNames.join(', '),
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
IconButton(
icon: Icon(Icons.swap_horiz, color: Colors.grey.shade700),
tooltip: 'Changer de famille',
onPressed: _busy ? null : _pickFamily,
),
],
),
),
),
);
}
Widget _placementSection() {
if (!_showsAmPlacement) return _scolariseCard();
if (_loadingAm) {
return SizedBox(
height: _placementHeight,
width: double.infinity,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey.shade600,
),
),
),
);
}
// Sans garde : toujours le cadre vide (comme après détachement AM).
if (_isSansGarde) return _emptyAmSlot();
if (_linkedAm != null) return _linkedAmCard();
return _emptyAmSlot();
}
Widget _buildFooter() {
return Row(
children: [
if (_canDelete)
OutlinedButton(
onPressed: _busy ? null : _delete,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade700,
),
child: _deleting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Supprimer'),
),
const Spacer(),
TextButton(
onPressed: _busy ? null : () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_canSubmit || _busy ? null : _save,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
widget.isCreating
? 'Créer'
: (_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
),
],
);
}
String _genderLabel(String gender) {
switch (gender) {
case 'H':
return 'Garçon';
case 'F':
return 'Fille';
case 'Autre':
return 'Inconnu';
default:
return gender;
}
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_parentsSubtitle() != null) ...[
const SizedBox(height: 4),
_parentsSubtitle()!,
],
],
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: const Icon(Icons.close),
onPressed: _busy ? null : () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: _mainRowHeight, child: _mainRow()),
const SizedBox(height: 12),
_buildFooter(),
],
),
),
],
),
),
);
}
}