fix(front): images validation dossier, traces debug, baseUrl via Env

- 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
This commit is contained in:
MARTIN Julien 2026-04-11 17:07:39 +02:00
parent 09289882d0
commit 3bfdadd301
6 changed files with 240 additions and 26 deletions

View File

@ -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<String, dynamic> 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,
);
}
}

View File

@ -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 lAPI.
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 lAPI (chemin type `/uploads/...`).
/// On préfixe avec [baseUrl] (/api/v1), pas seulement lhôte : Traefik nexpose 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';

View File

@ -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<DossierUnifie> 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<String, dynamic>) {
throw FormatException('Réponse invalide');
}
return DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
final dossier = DossierUnifie.fromJson(Map<String, dynamic>.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"}');

View File

@ -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<ValidationAmWizard> {
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
/// URL complète pour la photo : si relatif, on préfixe par lorigine de lAPI.
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<ValidationAmWizard> {
],
),
)
: Image.network(
photoUrl,
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: pw,
height: ph,

View File

@ -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<ValidationFamilyWizard> {
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 lAPI : /uploads est servi par Nest sous /api/v1/uploads/ (Traefik nexpose 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<ValidationFamilyWizard> {
/// 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<ValidationFamilyWizard> {
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
),
)
: Image.network(
photoUrl,
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.contain,
width: width,
height: height,

View File

@ -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 lURL nest 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 licô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<AuthNetworkImage> createState() => _AuthNetworkImageState();
}
class _AuthNetworkImageState extends State<AuthNetworkImage> {
late final Future<Map<String, String>?> _headersFuture;
@override
void initState() {
super.initState();
final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url);
_headersFuture =
isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders();
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] préparation chargement url=${widget.url} | '
'uploadPublic=$isPublic | avecBearer=${!isPublic}',
);
}
}
static Future<Map<String, String>?> _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<Map<String, String>?>(
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,
);
},
);
}
}