diff --git a/swiftyCompanion/README.md b/swiftyCompanion/README.md index ffd02fa..6c0f05b 100644 --- a/swiftyCompanion/README.md +++ b/swiftyCompanion/README.md @@ -13,8 +13,8 @@ Application mobile Flutter : recherche d’un login 42 et affichage du profil (A À la racine de **ce** dépôt Git (dossier qui contient `swifty_companion/`) : ```bash -cp .env.example swifty_companion/.env cd swifty_companion +cp ../.env.example .env ``` Édite `swifty_companion/.env` : renseigne `CLIENT_ID`, `CLIENT_SECRET` et `REDIRECT_URI` (voir commentaires dans `.env.example` à la racine). @@ -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é. +**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 ```bash diff --git a/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart b/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart index aab5972..67b66f4 100644 --- a/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart +++ b/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart @@ -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 { } } + /// 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 user) { final image = user['image']; if (image is Map) { final link = image['link']; if (link is String && link.isNotEmpty) return link; + + final versions = image['versions']; + if (versions is Map) { + 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 { } } +/// 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 _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, diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart b/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart index 173d38b..c49da3a 100644 --- a/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart +++ b/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart @@ -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 cdnImageHeaders() { + if (!isAuthenticated) return const {}; + return {'Authorization': 'Bearer $_accessToken'}; + } + Future> fetchMe() async { final response = await intraApiGet( Uri.parse('${IntraConfig.apiBaseUrl}/me'),