From 3bfdadd30153140abc43c59a4d3260d1c5dd8b67 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Apr 2026 17:07:39 +0200 Subject: [PATCH] fix(front): images validation dossier, traces debug, baseUrl via Env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiConfig.baseUrl dérivé de Env.apiBaseUrl (alignement API / origine médias). - AuthNetworkImage : uploads publics sans Bearer (CORS web) ; log erreurs en debug. - debugPrint kDebugMode : GET dossier, EnfantDossier.fromJson, absoluteMediaUrl, carte enfant. - ParentDossier id depuis user_id ; fallbacks présentation / photo. Made-with: Cursor --- frontend/lib/models/dossier_unifie.dart | 33 ++++- frontend/lib/services/api/api_config.dart | 44 +++++- frontend/lib/services/user_service.dart | 23 +++- .../widgets/admin/validation_am_wizard.dart | 15 +- .../admin/validation_family_wizard.dart | 21 +-- .../widgets/common/auth_network_image.dart | 130 ++++++++++++++++++ 6 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 frontend/lib/widgets/common/auth_network_image.dart diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index ae62b26..4ccd422 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:p_tits_pas/models/user.dart'; /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. @@ -107,7 +108,12 @@ class DossierFamille { numeroDossier: json['numero_dossier']?.toString(), parents: parentsList, enfants: enfantsList, - presentation: (json['texte_motivation'] ?? json['presentation'])?.toString(), + presentation: (json['texte_motivation'] ?? + json['presentation'] ?? + json['presentation_dossier'] ?? + json['texteMotivation'] ?? + json['presentationDossier']) + ?.toString(), ); } @@ -194,7 +200,27 @@ class EnfantDossier { String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); + static String? _optionalPhotoUrl(dynamic v) { + if (v == null) return null; + if (v is String) { + final s = v.trim(); + return s.isEmpty ? null : s; + } + final s = v.toString().trim(); + return s.isEmpty ? null : s; + } + factory EnfantDossier.fromJson(Map json) { + final rawPhoto = json['photo_url'] ?? json['photoUrl']; + final resolvedPhoto = _optionalPhotoUrl(rawPhoto); + if (kDebugMode) { + debugPrint( + '[PetitsPas/dossier] EnfantDossier.fromJson id=${json['id']} ' + 'prénom=${json['first_name'] ?? json['prenom']} | ' + 'photo_url brute=$rawPhoto (${rawPhoto?.runtimeType}) → ' + 'résolu=${resolvedPhoto ?? "∅"}', + ); + } return EnfantDossier( id: json['id']?.toString() ?? '', firstName: (json['first_name'] ?? json['prenom'])?.toString(), @@ -203,8 +229,9 @@ class EnfantDossier { gender: (json['gender'] ?? json['genre'])?.toString(), status: json['status']?.toString(), dueDate: json['due_date']?.toString(), - photoUrl: (json['photo_url'] ?? json['photoUrl'])?.toString(), - consentPhoto: json['consent_photo'] == true, + photoUrl: resolvedPhoto, + consentPhoto: + json['consent_photo'] == true || json['consentPhoto'] == true, ); } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 8c70367..9d605dd 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -1,5 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:p_tits_pas/config/env.dart'; + class ApiConfig { - static const String baseUrl = 'https://app.ptits-pas.fr/api/v1'; + /// Aligné sur [Env.apiBaseUrl] (`--dart-define=API_BASE_URL=...`) pour que les images `/uploads/...` visent le même hôte que l’API. + static String get baseUrl { + final root = Env.apiBaseUrl.replaceAll(RegExp(r'/+$'), ''); + return '$root/api/v1'; + } + + /// Origine (schéma + hôte + port) dérivée de [baseUrl], pour préfixer les chemins `/uploads/...`. + static String get apiOrigin { + final uri = Uri.parse(baseUrl); + if (uri.hasScheme && uri.host.isNotEmpty) { + return '${uri.scheme}://${uri.authority}'; + } + return baseUrl.replaceAll(RegExp(r'/api/v1/?.*'), ''); + } + + /// URL absolue pour une image renvoyée par l’API (chemin type `/uploads/...`). + /// On préfixe avec [baseUrl] (…/api/v1), pas seulement l’hôte : Traefik n’expose souvent que `/api`. + static String absoluteMediaUrl(String? pathOrUrl) { + if (pathOrUrl == null || pathOrUrl.trim().isEmpty) { + if (kDebugMode) { + debugPrint('[PetitsPas/media] absoluteMediaUrl: entrée vide (null ou "")'); + } + return ''; + } + final u = pathOrUrl.trim(); + if (u.startsWith('http://') || u.startsWith('https://')) { + if (kDebugMode) { + debugPrint('[PetitsPas/media] absoluteMediaUrl: déjà absolu → $u'); + } + return u; + } + final base = baseUrl.replaceAll(RegExp(r'/+$'), ''); + final out = u.startsWith('/') ? '$base$u' : '$base/$u'; + if (kDebugMode) { + debugPrint( + '[PetitsPas/media] absoluteMediaUrl: base=$base | chemin brut="$u" → "$out"', + ); + } + return out; + } // Auth endpoints static const String login = '/auth/login'; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index a70d011..c0ab07e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -92,8 +93,12 @@ class UserService { /// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107. static Future getDossier(String numeroDossier) async { final encoded = Uri.encodeComponent(numeroDossier); + final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'); + if (kDebugMode) { + debugPrint('[PetitsPas/dossier] GET $uri'); + } final response = await http.get( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'), + uri, headers: await _headers(), ); if (response.statusCode == 404) { @@ -113,7 +118,21 @@ class UserService { if (decoded is! Map) { throw FormatException('Réponse invalide'); } - return DossierUnifie.fromJson(Map.from(decoded)); + final dossier = DossierUnifie.fromJson(Map.from(decoded)); + if (kDebugMode) { + debugPrint( + '[PetitsPas/dossier] réponse OK type=${dossier.type} | ' + 'ApiConfig.baseUrl=${ApiConfig.baseUrl} | apiOrigin=${ApiConfig.apiOrigin}', + ); + if (dossier.isFamily) { + final f = dossier.asFamily; + debugPrint( + '[PetitsPas/dossier] famille ${f.numeroDossier} | ' + '${f.enfants.length} enfant(s)', + ); + } + } + return dossier; } catch (e) { if (e is FormatException) rethrow; throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}'); diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index 2c1ae05..5a18332 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -7,6 +7,7 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; import 'validation_valider_confirm_dialog.dart'; @@ -110,14 +111,8 @@ class _ValidationAmWizardState extends State { static const double _proColumnMinWidth = 260; static const double _photoColumnMinWidth = 160; - /// URL complète pour la photo : si relatif, on préfixe par l’origine de l’API. - static String _fullPhotoUrl(String? url) { - if (url == null || url.trim().isEmpty) return ''; - final u = url.trim(); - if (u.startsWith('http://') || u.startsWith('https://')) return u; - final base = ApiConfig.baseUrl.replaceAll(RegExp(r'/+$'), ''); - return u.startsWith('/') ? '$base$u' : '$base/$u'; - } + /// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`). + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); Widget _buildPhotoSection(AppUser u) { final photoUrl = _fullPhotoUrl(u.photoUrl); @@ -186,8 +181,8 @@ class _ValidationAmWizardState extends State { ], ), ) - : Image.network( - photoUrl, + : AuthNetworkImage( + url: photoUrl, fit: BoxFit.cover, width: pw, height: ph, diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 2bbe511..bc442d6 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -8,6 +9,7 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; import 'validation_valider_confirm_dialog.dart'; @@ -134,14 +136,7 @@ class _ValidationFamilyWizardState extends State { 3: [2, 5] }; // Code postal étroit, Ville large - static String _fullPhotoUrl(String? url) { - if (url == null || url.trim().isEmpty) return ''; - final u = url.trim(); - if (u.startsWith('http://') || u.startsWith('https://')) return u; - // Même origine que l’API : /uploads est servi par Nest sous /api/v1/uploads/ (Traefik n’expose souvent que /api). - final base = ApiConfig.baseUrl.replaceAll(RegExp(r'/+$'), ''); - return u.startsWith('/') ? '$base$u' : '$base/$u'; - } + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); @override Widget build(BuildContext context) { @@ -349,6 +344,12 @@ class _ValidationFamilyWizardState extends State { /// 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); + if (kDebugMode) { + debugPrint( + '[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | ' + 'photoUrl modèle=${e.photoUrl ?? "∅"} | url affichée=${photoUrl.isEmpty ? "∅" : photoUrl}', + ); + } final columnStatusLabel = _enfantColumnStatusLabel(e); return ClipRRect( borderRadius: BorderRadius.circular(_enfantCardRadius), @@ -530,8 +531,8 @@ class _ValidationFamilyWizardState extends State { child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), ), ) - : Image.network( - photoUrl, + : AuthNetworkImage( + url: photoUrl, fit: BoxFit.contain, width: width, height: height, diff --git a/frontend/lib/widgets/common/auth_network_image.dart b/frontend/lib/widgets/common/auth_network_image.dart new file mode 100644 index 0000000..8562963 --- /dev/null +++ b/frontend/lib/widgets/common/auth_network_image.dart @@ -0,0 +1,130 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +/// [Image.network] avec en-tête `Authorization` uniquement si l’URL n’est pas un fichier statique public. +/// +/// Les chemins `/uploads/...` sont servis sans auth (voir back + Traefik) : ne pas envoyer de Bearer, +/// sinon en **cross-origin** (ex. admin Flutter web en local, API en prod) le navigateur peut bloquer +/// la requête (CORS) et afficher l’icône « image cassée ». +class AuthNetworkImage extends StatefulWidget { + const AuthNetworkImage({ + super.key, + required this.url, + this.width, + this.height, + this.fit = BoxFit.cover, + this.loadingBuilder, + this.errorBuilder, + }); + + final String url; + final double? width; + final double? height; + final BoxFit fit; + final ImageLoadingBuilder? loadingBuilder; + final ImageErrorWidgetBuilder? errorBuilder; + + static bool isPublicUploadUrl(String url) { + final u = url.toLowerCase(); + return u.contains('/uploads/'); + } + + @override + State createState() => _AuthNetworkImageState(); +} + +class _AuthNetworkImageState extends State { + late final Future?> _headersFuture; + + @override + void initState() { + super.initState(); + final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url); + _headersFuture = + isPublic ? Future?>.value(null) : _loadHeaders(); + if (kDebugMode) { + debugPrint( + '[PetitsPas/image] préparation chargement url=${widget.url} | ' + 'uploadPublic=$isPublic | avecBearer=${!isPublic}', + ); + } + } + + static Future?> _loadHeaders() async { + final t = await TokenService.getToken(); + if (t == null || t.isEmpty) return null; + return {'Authorization': 'Bearer $t'}; + } + + ImageErrorWidgetBuilder _wrapErrorBuilder() { + return (BuildContext context, Object error, StackTrace? stackTrace) { + if (kDebugMode) { + debugPrint( + '[PetitsPas/image] ❌ échec chargement url=${widget.url} | erreur=$error', + ); + } + final inner = widget.errorBuilder; + if (inner != null) { + return inner(context, error, stackTrace); + } + return ColoredBox( + color: Colors.grey.shade200, + child: Center( + child: Icon(Icons.broken_image_outlined, color: Colors.grey.shade500), + ), + ); + }; + } + + @override + Widget build(BuildContext context) { + final err = _wrapErrorBuilder(); + + if (AuthNetworkImage.isPublicUploadUrl(widget.url)) { + return Image.network( + widget.url, + width: widget.width, + height: widget.height, + fit: widget.fit, + loadingBuilder: widget.loadingBuilder, + errorBuilder: err, + ); + } + + return FutureBuilder?>( + future: _headersFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return SizedBox( + width: widget.width, + height: widget.height, + child: ColoredBox( + color: Colors.grey.shade200, + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + ); + } + final headers = snapshot.data; + if (kDebugMode && headers != null) { + debugPrint('[PetitsPas/image] requête avec en-tête Authorization (Bearer présent)'); + } + return Image.network( + widget.url, + width: widget.width, + height: widget.height, + fit: widget.fit, + headers: headers, + loadingBuilder: widget.loadingBuilder, + errorBuilder: err, + ); + }, + ); + } +}