fix(profil): photo CDN avec Bearer OAuth et doc soutenance

Charge les avatars depuis cdn.intra.42.fr avec le même access token,
parse image.versions, et documente le flux pour la correction.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-21 10:38:16 +02:00
parent 1ce33a69d1
commit a6ab13ee2f
6 changed files with 374 additions and 199 deletions

View File

@@ -1,6 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../services/intra_api_exception.dart';
import '../services/intra_auth_service.dart';
import '../services/intra_profile_service.dart';
import '../theme/swifty_ui.dart';
@@ -56,11 +60,22 @@ class _ProfileScreenState extends State<ProfileScreen> {
}
}
/// Extrait l’URL photo depuis la fiche `GET /v2/users/{login}` (critère sujet : photo de profil).
///
/// Ordre : `image.link` → `image.versions` (large…micro) → champ legacy `image_url`.
String? _imageUrl(Map<String, dynamic> user) {
final image = user['image'];
if (image is Map<String, dynamic>) {
final link = image['link'];
if (link is String && link.isNotEmpty) return link;
final versions = image['versions'];
if (versions is Map<String, dynamic>) {
for (final key in ['large', 'medium', 'small', 'micro']) {
final v = versions[key];
if (v is String && v.isNotEmpty) return v;
}
}
}
final url = user['image_url'];
if (url is String && url.isNotEmpty) return url;
@@ -172,6 +187,71 @@ class _ProfileScreenState extends State<ProfileScreen> {
}
}
/// Avatar profil : charge l’image via HTTP avec le Bearer OAuth (`IntraAuthService.cdnImageHeaders`).
///
/// Un simple `NetworkImage(url)` sans token échoue souvent sur le CDN 42. Si le chargement
/// échoue ou l’URL est absente, affiche l’initiale du login (conforme encadré confidentialité sujet).
class _IntraProfileAvatar extends StatefulWidget {
const _IntraProfileAvatar({required this.login, required this.imageUrl});
final String login;
final String? imageUrl;
@override
State<_IntraProfileAvatar> createState() => _IntraProfileAvatarState();
}
class _IntraProfileAvatarState extends State<_IntraProfileAvatar> {
Uint8List? _bytes;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(covariant _IntraProfileAvatar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) {
_bytes = null;
_load();
}
}
/// GET binaire sur l’URL CDN ; même session que les appels `api.intra.42.fr`.
Future<void> _load() async {
final url = widget.imageUrl;
if (url == null || url.isEmpty) return;
final headers = IntraAuthService.instance.cdnImageHeaders();
try {
final response = await http.get(Uri.parse(url), headers: headers);
if (response.statusCode == 200 && mounted) {
setState(() => _bytes = response.bodyBytes);
}
} catch (_) {
// Initiale affichée en repli.
}
}
Widget _initial() {
final letter =
widget.login.isNotEmpty ? widget.login[0].toUpperCase() : '?';
return Text(letter, style: const TextStyle(fontSize: 32));
}
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: 56,
backgroundImage:
_bytes != null ? MemoryImage(_bytes!) : null,
child: _bytes == null ? _initial() : null,
);
}
}
class _ErrorBody extends StatelessWidget {
const _ErrorBody({required this.message, required this.onRetry});
@@ -206,17 +286,7 @@ class _ProfileHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
CircleAvatar(
radius: 56,
backgroundImage:
imageUrl != null ? NetworkImage(imageUrl!) : null,
child: imageUrl == null
? Text(
login.isNotEmpty ? login[0].toUpperCase() : '?',
style: const TextStyle(fontSize: 32),
)
: null,
),
_IntraProfileAvatar(login: login, imageUrl: imageUrl),
const SizedBox(height: 12),
Text(
login,