2 Commits

Author SHA1 Message Date
eaacef0cb6 fix(profil): photo CDN Bearer (aligné main, branche rendu)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 10:38:20 +02:00
63886d2df5 Un seul .env.example à la racine du paquet de rendu."
"
2026-10-21 09:17:17 +02:00
5 changed files with 95 additions and 22 deletions

View File

@@ -53,6 +53,8 @@ Documentation API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
Certaines données peuvent s’afficher comme « — » : l’API peut masquer des champs selon la confidentialité. Certaines données peuvent s’afficher comme « — » : l’API peut masquer des champs selon la confidentialité.
**Photo :** l’URL est fournie par l’API (`image` sur la fiche user) ; le fichier JPEG est chargé depuis `cdn.intra.42.fr` avec le **même** access token OAuth (pas un second login).
## Vérifications avant soutenance ## Vérifications avant soutenance
```bash ```bash

View File

@@ -1,9 +0,0 @@
# Copier en .env dans CE dossier (swifty_companion/) :
# cp .env.example .env
# Puis éditer .env avec ton UID, SECRET et REDIRECT_URI Intra.
#
# Modèle détaillé (mêmes variables) : ../.env.example à la racine du dépôt.
CLIENT_ID=
CLIENT_SECRET=
REDIRECT_URI=com.example.swiftycompanion://callback

View File

@@ -5,8 +5,8 @@ Le projet s’exécute depuis ce dossier.
## Démarrage rapide ## Démarrage rapide
```bash ```bash
cp .env.example .env # ou : cp ../.env.example .env cp ../.env.example .env
# Éditer .env (CLIENT_ID, CLIENT_SECRET, REDIRECT_URI) # Éditer .env (CLIENT_ID, CLIENT_SECRET, REDIRECT_URI) — voir commentaires dans ../.env.example
flutter pub get flutter pub get
flutter run -d <device> flutter run -d <device>
``` ```

View File

@@ -1,6 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../services/intra_api_exception.dart'; import '../services/intra_api_exception.dart';
import '../services/intra_auth_service.dart';
import '../services/intra_profile_service.dart'; import '../services/intra_profile_service.dart';
import '../theme/swifty_ui.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) { String? _imageUrl(Map<String, dynamic> user) {
final image = user['image']; final image = user['image'];
if (image is Map<String, dynamic>) { if (image is Map<String, dynamic>) {
final link = image['link']; final link = image['link'];
if (link is String && link.isNotEmpty) return 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']; final url = user['image_url'];
if (url is String && url.isNotEmpty) return 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 { class _ErrorBody extends StatelessWidget {
const _ErrorBody({required this.message, required this.onRetry}); const _ErrorBody({required this.message, required this.onRetry});
@@ -206,17 +286,7 @@ class _ProfileHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
CircleAvatar( _IntraProfileAvatar(login: login, imageUrl: imageUrl),
radius: 56,
backgroundImage:
imageUrl != null ? NetworkImage(imageUrl!) : null,
child: imageUrl == null
? Text(
login.isNotEmpty ? login[0].toUpperCase() : '?',
style: const TextStyle(fontSize: 32),
)
: null,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
login, login,

View File

@@ -125,6 +125,16 @@ class IntraAuthService {
}; };
} }
/// En-têtes pour télécharger la photo sur `cdn.intra.42.fr`.
///
/// L’URL vient du JSON user (`image.link` / `versions`). Le CDN refuse souvent
/// les requêtes sans Bearer (403). On réutilise le **même** access token que pour
/// l’API — pas un second OAuth. On n’envoie pas `Accept: application/json` ici.
Map<String, String> cdnImageHeaders() {
if (!isAuthenticated) return const {};
return {'Authorization': 'Bearer $_accessToken'};
}
Future<Map<String, dynamic>> fetchMe() async { Future<Map<String, dynamic>> fetchMe() async {
final response = await intraApiGet( final response = await intraApiGet(
Uri.parse('${IntraConfig.apiBaseUrl}/me'), Uri.parse('${IntraConfig.apiBaseUrl}/me'),