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:
parent
09289882d0
commit
3bfdadd301
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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"}');
|
||||
|
||||
@ -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 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<ValidationAmWizard> {
|
||||
],
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
photoUrl,
|
||||
: AuthNetworkImage(
|
||||
url: photoUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: pw,
|
||||
height: ph,
|
||||
|
||||
@ -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 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<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,
|
||||
|
||||
130
frontend/lib/widgets/common/auth_network_image.dart
Normal file
130
frontend/lib/widgets/common/auth_network_image.dart
Normal 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 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<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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user