From 16001f695b68110487beae85a450bc0297f1dbab Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 12:34:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(#138):=20carte=20AM=20d=C3=A9di=C3=A9e,=20t?= =?UTF-8?q?itre=20placement=20et=20consent=5Fphoto=20au=20payload.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Améliore la zone AM/scolarisation (carte dédiée + titre), envoie consent_photo à l'inscription parent, et parse le consentement enfant de façon plus robuste (heuristique photo tant que le back ne persiste pas). Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 38 ++- .../utils/parent_registration_payload.dart | 3 + .../common/admin_child_detail_modal.dart | 226 ++++++++++++++---- 3 files changed, 208 insertions(+), 59 deletions(-) diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 5c7c84d..696efa4 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -48,17 +48,28 @@ class EnfantAdminModel { } } + final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?; + final hasPhoto = (photoUrl ?? '').trim().isNotEmpty; + // L'inscription parent exigeait le consentement pour envoyer la photo, mais + // le back a longtemps forcé consent_photo=false (voir reprise_mapper). + final consentFromApi = _parseBool(json['consent_photo']) || + _parseBool(json['consentement_photo']) || + _parseBool(json['consentPhoto']); + return EnfantAdminModel( id: (json['id'] ?? '').toString(), - firstName: json['first_name'] as String?, - lastName: json['last_name'] as String?, - gender: json['gender'] as String?, - birthDate: _dateString(json['birth_date']), - dueDate: _dateString(json['due_date']), - status: normalizeEnfantStatus(json['status']?.toString()), - photoUrl: json['photo_url'] as String?, - consentPhoto: json['consent_photo'] == true, - isMultiple: json['is_multiple'] == true, + firstName: json['first_name'] as String? ?? json['prenom'] as String?, + lastName: json['last_name'] as String? ?? json['nom'] as String?, + gender: json['gender'] as String? ?? json['genre'] as String?, + birthDate: _dateString(json['birth_date'] ?? json['date_naissance']), + dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']), + status: normalizeEnfantStatus( + (json['status'] ?? json['statut'])?.toString(), + ), + photoUrl: photoUrl, + consentPhoto: consentFromApi || hasPhoto, + isMultiple: _parseBool(json['is_multiple']) || + _parseBool(json['est_multiple']), parentLinks: links, ); } @@ -76,6 +87,15 @@ class EnfantAdminModel { }; } + static bool _parseBool(dynamic value) { + if (value == true || value == 1) return true; + if (value is String) { + final s = value.trim().toLowerCase(); + return s == 'true' || s == '1' || s == 't' || s == 'yes'; + } + return false; + } + static String? _dateString(dynamic v) { if (v == null) return null; if (v is String) return v.split('T').first; diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 70a9459..055732e 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -198,6 +198,9 @@ class ParentRegistrationPayload { map['photo_base64'] = photo.$1; map['photo_filename'] = photo.$2; } + // Consentement photo enfant (le back l'ignore encore à l'inscription — + // à brancher côté API ; en attendant le front admin infère via photo). + map['consent_photo'] = c.photoConsent; return map; } diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index df063f2..70b36a3 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.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/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_user_card.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 (ticket #138) — format paysage comme fiche AM. class AdminChildDetailModal extends StatefulWidget { @@ -610,9 +611,15 @@ class _AdminChildDetailModalState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _fieldsGrid(), - const Spacer(), - const SizedBox(height: 16), - _placementSection(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: Align( + alignment: Alignment.bottomCenter, + child: _placementBlock(), + ), + ), + ), ], ), ), @@ -625,6 +632,7 @@ class _AdminChildDetailModalState extends State { Widget _scolariseCard() { return Container( height: _placementHeight, + width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( colors: [ @@ -692,36 +700,54 @@ class _AdminChildDetailModalState extends State { 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: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.face_outlined, size: 28, color: Colors.grey.shade400), - const SizedBox(width: 12), - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Aucune assistante maternelle', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.grey.shade700, - ), - ), - Text( - 'Cliquer pour choisir une assistante', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - ), - ], + 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, + ), + ), + ], + ), ), - const SizedBox(width: 8), Icon(Icons.add_circle_outline, color: Colors.grey.shade500), ], ), @@ -732,42 +758,142 @@ class _AdminChildDetailModalState extends State { Widget _linkedAmCard() { final am = _linkedAm!; - return SizedBox( - height: _placementHeight, - child: AdminUserCard( - margin: EdgeInsets.zero, - title: am.user.fullName, - avatarUrl: am.user.photoUrl, - fallbackIcon: Icons.face, - onCardTap: _busy ? null : _openLinkedAm, - subtitleLines: [ - if (am.residenceCity != null && am.residenceCity!.trim().isNotEmpty) - 'Zone : ${am.residenceCity!.trim()}', - if (am.approvalNumber != null && am.approvalNumber!.trim().isNotEmpty) - 'Agrément : ${am.approvalNumber!.trim()}', - ], - actions: [ - IconButton( - icon: const Icon(Icons.open_in_new), - tooltip: 'Ouvrir la fiche AM', - onPressed: _busy ? null : _openLinkedAm, + 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)), ), - IconButton( - icon: Icon(Icons.link_off, color: Colors.orange.shade800), - tooltip: 'Détacher', - onPressed: _busy ? null : _detachAm, + 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 => + _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), + _placementSection(), + ], + ); + } + Widget _placementSection() { if (!_showsAmPlacement) return _scolariseCard(); if (_loadingAm || _placementBusy) { return SizedBox( height: _placementHeight, + width: double.infinity, child: Center( child: SizedBox( width: 24,