Squash merge develop → master. - Fiche parent éditable (co-parent, PATCH fiche, GET /parents) - Fiche AM 3 onglets (PATCH fiche, rattacher/détacher enfants) - Table enfants_assistantes_maternelles + enum garde/sans_garde - Migration SQL + BDD.sql canonique - Correctifs recette : @Get() parents, DTO fiche AM, fix NIR Co-authored-by: Cursor <cursoragent@cursor.com>
120 lines
3.5 KiB
Dart
120 lines
3.5 KiB
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.alignment = Alignment.center,
|
||
this.loadingBuilder,
|
||
this.errorBuilder,
|
||
});
|
||
|
||
final String url;
|
||
final double? width;
|
||
final double? height;
|
||
final BoxFit fit;
|
||
final AlignmentGeometry alignment;
|
||
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();
|
||
}
|
||
|
||
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) {
|
||
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,
|
||
alignment: widget.alignment,
|
||
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;
|
||
return Image.network(
|
||
widget.url,
|
||
width: widget.width,
|
||
height: widget.height,
|
||
fit: widget.fit,
|
||
alignment: widget.alignment,
|
||
headers: headers,
|
||
loadingBuilder: widget.loadingBuilder,
|
||
errorBuilder: err,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|