diff --git a/docs/pedagogie/etape-04-vue-profil.md b/docs/pedagogie/etape-04-vue-profil.md index 5ddf638..ad033c1 100644 --- a/docs/pedagogie/etape-04-vue-profil.md +++ b/docs/pedagogie/etape-04-vue-profil.md @@ -6,7 +6,7 @@ | Exigence | Cette étape | |----------|-------------| -| ≥ 4 infos + photo | login (titre / avatar), email, téléphone, niveau cursus, localisation, wallet | +| ≥ 4 infos + photo | login (titre / avatar), email, téléphone, niveau cursus, localisation, wallet — photo : URL API puis GET CDN avec Bearer (`_IntraProfileAvatar`) | | Skills | Niveau entier + **pourcentage** (partie décimale du `level` API) | | Projets | Statuts **`finished`** et **`failed`** | | Erreurs | Réseau, 401, réponse API inattendue — message + réessai | @@ -46,6 +46,14 @@ Accept: application/json **Confidentialité :** certains champs peuvent être masqués par l’API selon le profil consulté — afficher `—` si absent (sujet OAuth). +### Photo de profil + +1. **URL** : extraite du JSON user (`_imageUrl` dans `profile_screen.dart`) — `image.link`, puis `image.versions`, puis `image_url`. +2. **Fichier** : `GET` sur `cdn.intra.42.fr` avec `Authorization: Bearer` (`IntraAuthService.cdnImageHeaders`) — le CDN refuse souvent les requêtes sans token. +3. **UI** : widget `_IntraProfileAvatar` (bytes en mémoire + `CircleAvatar`) ; sinon initiale du login. + +Détail oral : [fiche-soutenance-orale.md](fiche-soutenance-orale.md) §5 « Photo de profil ». + ### Skills — niveau et pourcentage L’API renvoie un `level` flottant (ex. `8.42`) : diff --git a/docs/pedagogie/fiche-soutenance-orale.md b/docs/pedagogie/fiche-soutenance-orale.md index dc30795..172a9f8 100644 --- a/docs/pedagogie/fiche-soutenance-orale.md +++ b/docs/pedagogie/fiche-soutenance-orale.md @@ -1,11 +1,30 @@ # Fiche soutenance orale — Swifty Companion -> **Lot 3** du [plan de préparation](00-plan-preparation-soutenance.md). -> **Usage :** répétition à voix haute, ordre de démo, réponses courtes. -> **Cartographie détaillée sujet ↔ code :** [guide-soutenance-realisation.md](guide-soutenance-realisation.md). -> **Dépôt / `.env` / PC école :** [fiche-depot-soutenance.md](fiche-depot-soutenance.md). +> **Usage :** répétition à voix haute, ordre de démo, **repères code** pour l’IDE. +> **Racine code à ouvrir en soutenance :** `swiftyCompanion/swifty_companion/` +> **Cartographie sujet ↔ code :** [guide-soutenance-realisation.md](guide-soutenance-realisation.md) +> **Dépôt / `.env` :** [fiche-depot-soutenance.md](fiche-depot-soutenance.md) -**Durée type peer-eval :** 5 à 15 min selon l’évaluateur — viser **8 min** de démo + questions. +**Durée type peer-eval :** 5 à 15 min — viser **8 min** de démo + questions. + +--- + +## Index rapide — où ouvrir quoi + +| Sujet oral | Fichier (sous `swifty_companion/lib/` ou `test/`) | +|------------|---------------------------------------------------| +| Démarrage, `.env`, thème | `main.dart` | +| Secrets, URLs API v2 | `config/intra_config.dart` | +| Connexion OAuth, tokens, refresh | `services/intra_auth_service.dart` | +| GET + 401 → refresh + 429 | `services/intra_http.dart` | +| Recherche login | `services/intra_user_service.dart` + `screens/search_screen.dart` | +| Profil, skills, projets | `services/intra_profile_service.dart` + `screens/profile_screen.dart` | +| **Photo profil (URL + CDN Bearer)** | `profile_screen.dart` → `_imageUrl`, `_IntraProfileAvatar` · `intra_auth_service.dart` → `cdnImageHeaders` | +| Messages d’erreur | `services/intra_api_exception.dart` | +| Parse JSON token (bonus) | `services/intra_oauth_tokens.dart` + `test/intra_oauth_tokens_test.dart` | +| Layout responsive | `theme/swifty_ui.dart` | +| Redirect OAuth Android | `android/app/src/main/AndroidManifest.xml` | +| Bouton DEBUG (démo bonus) | `screens/search_screen.dart` (bloc `kDebugMode`) | --- @@ -14,244 +33,310 @@ | Check | Action | |-------|--------| | Device | Téléphone chargé, USB débogage ou Wi‑Fi ADB OK | -| `.env` | Présent dans `swiftyCompanion/swifty_companion/.env`, **pas** affiché à l’écran | -| OAuth Intra | App avec scopes **public** + **projects**, redirect = `REDIRECT_URI` du `.env` | -| Terminal prêt | Dossier `swiftyCompanion/swifty_companion` | -| Git école | Pas de `.env` dans `git status` si on montre le repo | +| `.env` | `swiftyCompanion/swifty_companion/.env`, **pas** affiché à l’écran | +| OAuth Intra | Scopes **public** + **projects**, redirect = `.env` | +| Terminal | `cd swiftyCompanion/swifty_companion` | +| Git école | Pas de `.env` dans `git status` | --- ## 1. Introduction (~30 s) -**À dire (modèle) :** +**À dire :** -> « Swifty Companion est une app Flutter qui se connecte une fois à l’API 42 en OAuth2, puis permet de chercher un login et d’afficher son profil : photo, coordonnées, niveau cursus, skills avec pourcentage, et projets validés ou en échec. Les secrets sont dans un `.env` local, ignoré par Git. J’ai deux écrans et un seul token réutilisé pour toutes les requêtes API. » +> « Swifty Companion : Flutter, OAuth2 Intra une fois, puis recherche par login et profil (photo, champs, skills avec %, projets validés et failed). Secrets dans `.env` local, ignoré par Git. Deux écrans, un seul access token pour toutes les requêtes API. » -| Point | Détail à mentionner si demandé | -|-------|--------------------------------| -| Sujet | Mobile Initiation v4 — API Intra **v2** | -| Stack | Flutter / Dart, `flutter_appauth`, `http`, `flutter_dotenv` | -| Entrée app | `lib/main.dart` → `SearchScreen` | +| Point | Code | +|-------|------| +| Entrée + chargement `.env` | ```7:31:swiftyCompanion/swifty_companion/lib/main.dart``` | +| Vue 1 au lancement | `home: const SearchScreen()` — même fichier | --- -## 2. Sécurité et fichier `.env` (~1 min) +## 2. Sécurité et `.env` (~1 min) -### Ce que le sujet impose (éliminatoire) +**Sujet :** secrets en `.env` local, **ignorés par Git** — exposition publique = échec direct. -- Identifiants **uniquement** dans `.env` **local**. -- `.env` **ignoré par Git** — exposition publique = **échec direct**. -- Le rendu Vogosphere contient **`.env.example`**, pas de vrais secrets. +**Fichier exécuté :** `swiftyCompanion/swifty_companion/.env` (asset dans `pubspec.yaml`). -### Où est le fichier qui tourne +| Variable | Intra 42 | +|----------|----------| +| `CLIENT_ID` | UID | +| `CLIENT_SECRET` | SECRET | +| `REDIRECT_URI` | URI de redirection | + +**Scopes** (Intra, pas dans `.env`) : `public` + `projects`. + +| Rôle | Code | +|------|------| +| Lecture `.env` + `isConfigured` | ```9:14:swiftyCompanion/swifty_companion/lib/config/intra_config.dart``` | +| `dotenv.load` au boot | ```7:9:swiftyCompanion/swifty_companion/lib/main.dart``` | +| Message si `.env` incomplet | ```109:118:swiftyCompanion/swifty_companion/lib/screens/search_screen.dart``` | +| Modèle versionné (rendu) | `swiftyCompanion/.env.example` | + +--- + +## 3. Connexion 42 — OAuth2 pas à pas (~1–2 min) + +### Ce qui se passe quand tu appuies sur « Se connecter avec 42 » + +1. **`SearchScreen._connect()`** appelle **`IntraAuthService.signIn()`** + → ```37:45:swiftyCompanion/swifty_companion/lib/screens/search_screen.dart``` + +2. **`authorizeAndExchangeCode`** (`flutter_appauth`) : navigateur Intra → tu te connectes → redirect `com.example.swiftycompanion://callback` → l’app reçoit les tokens. + → ```40:50:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart``` + Scopes : `public`, `projects` — ```46:46:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart``` + +3. **Stockage en mémoire** : `_accessToken` + `_refreshToken` + → ```57:61:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart``` + +4. **Vérification session** : `GET /v2/me` avec le même service + → ```128:133:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart``` + +5. **Redirect Android** (schéma + host) + → ```26:38:swiftyCompanion/swifty_companion/android/app/src/main/AndroidManifest.xml``` + +**Critère sujet n°9 :** après cette étape, **plus de OAuth** pour chaque recherche — seulement `Authorization: Bearer …` +→ ```117:125:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart``` (`authHeaders`) + +**Erreurs OAuth** (annulation, plateforme) : ```50:68:swiftyCompanion/swifty_companion/lib/screens/search_screen.dart``` + +--- + +## 4. Tokens : access, refresh, et bouton DEBUG (à maîtriser pour le bonus) + +> Section la plus importante si l’évaluateur demande le **bonus** ou le bouton **DEBUG**. + +### Vocabulaire simple (à réciter) + +| Jeton | Rôle | Analogie | +|-------|------|----------| +| **Access token** | Prouve à l’API que tu es autorisé ; envoyé sur chaque `GET` / `POST` API | **Badge** d’entrée — durée courte | +| **Refresh token** | Permet d’obtenir un **nouveau** access token **sans** rouvrir le navigateur OAuth | **Clé de renouvellement** du badge | + +Les deux sont obtenus **une fois** au login OAuth, puis gardés dans **`IntraAuthService.instance`** (singleton). + +### Flux normal (sans bouton DEBUG) ```text -swiftyCompanion/swifty_companion/.env +[Connexion 42] → access + refresh en RAM +[Rechercher] → GET /users?filter[login]= avec Bearer access +[Profil] → GET /users/{login}, projects_users, etc. même Bearer ``` -Chargé au démarrage : `dotenv.load()` dans `main.dart`, déclaré en asset dans `pubspec.yaml`. +Recherche : ```82:95:swiftyCompanion/swifty_companion/lib/screens/search_screen.dart``` → ```19:33:swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart``` -### Les trois variables (à expliquer sans lire les valeurs à l’écran) +### Quand l’access token expire (bonus sujet) -| Variable | Rôle | Où la trouver sur l’Intra | -|----------|------|---------------------------| -| `CLIENT_ID` | Identifiant de l’app OAuth | Champ **UID** | -| `CLIENT_SECRET` | Échange code → tokens | Champ **SECRET** (privé) | -| `REDIRECT_URI` | Retour navigateur → app | **URI de redirection** (ex. `com.example.swiftycompanion://callback`) | +1. Un `GET` API renvoie **401 Unauthorized**. +2. **`intraApiGet`** appelle **`refreshAccessToken()`** une fois, puis **rejoue** le même `GET`. + → ```19:24:swiftyCompanion/swifty_companion/lib/services/intra_http.dart``` +3. Le refresh fait **`POST /oauth/token`** avec `grant_type=refresh_token`. + → ```88:114:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart``` +4. Si le refresh échoue → **`IntraUnauthorizedException`** (« Session expirée… ») + → ```25:27:swiftyCompanion/swifty_companion/lib/services/intra_api_exception.dart``` -**Scopes** (cochés sur l’Intra, pas dans `.env`) : **`public`** + **`projects`** — sans `projects`, les projets du profil renvoient **403**. +**À dire :** « Le bonus n’est pas un second OAuth à chaque fois : c’est le refresh automatique sur 401, puis la même requête est relancée. » -**Phrase clé :** « Le correcteur copie `.env.example` vers `.env` et met ses propres clés ; mon dépôt Git ne contient jamais le SECRET. » +### Le bouton « DEBUG : simuler token expiré » — qu’est-ce que ça fait **vraiment** ? + +| Question | Réponse courte | +|----------|----------------| +| C’est demandé par le sujet ? | **Non.** C’est un **raccourci de démo** en développement. | +| C’est de la triche ? | **Non.** Il ne contourne pas l’API : il force la même situation qu’un access token périmé, pour tester le **vrai** flux refresh. | +| Visible en soutenance release ? | **Non.** Compilé seulement si `kDebugMode` (ex. `flutter run`). Absent en `flutter build apk --release`. | + +**Mécanisme en 3 lignes de code :** + +```143:146:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart + void debugInvalidateAccessToken() { + if (!canRefresh) return; + _accessToken = 'invalid_access_token_for_refresh_test'; + } +``` + +- Le **refresh token n’est pas touché**. +- L’**access token** devient une valeur volontairement fausse → la prochaine API répond **401** → le chemin **bonus** (§ ci-dessus) s’exécute. + +**UI du bouton :** + +```189:205:swiftyCompanion/swifty_companion/lib/screens/search_screen.dart + // Bonus refresh : invalide l'access token en mémoire (build debug uniquement). + if (kDebugMode) ...[ + ... + _auth.debugInvalidateAccessToken(); + ... + child: const Text('DEBUG : simuler token expiré'), +``` + +### Script oral démo DEBUG (~45 s) + +1. « Je me connecte une fois à l’API 42. » +2. « Une recherche fonctionne : un seul access token, pas de nouvelle fenêtre OAuth. » +3. « En build debug j’ai un bouton qui **simule** un access token expiré — il ne supprime pas le refresh token. » *(montrer le bouton)* +4. « Je recherche à nouveau : l’API renvoie 401, mon `intra_http` déclenche le refresh, puis relance le GET — le profil s’ouvre sans repasser par le navigateur. » +5. « En production le bouton n’existe pas ; le mécanisme reste le même quand le token expire vraiment. » + +**Si pas de téléphone / pas de debug :** `flutter test` sur le parse token +→ `swiftyCompanion/swifty_companion/test/intra_oauth_tokens_test.dart` +→ ```12:20:swiftyCompanion/swifty_companion/lib/services/intra_oauth_tokens.dart``` + +**Limite à assumer :** tokens en **mémoire** — fermer l’app = nouvelle connexion OAuth (`signOut` / kill) +→ ```64:67:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart``` --- -## 3. Architecture (~1–2 min) +## 5. Architecture des écrans et services -### Les deux vues +### Deux vues -| Vue | Fichier | Rôle | -|-----|---------|------| -| **1 — Recherche** | `lib/screens/search_screen.dart` | Connexion OAuth, champ login, navigation vers profil | -| **2 — Profil** | `lib/screens/profile_screen.dart` | Données agrégées, erreur + Réessayer | +| Vue | Fichier | Code clé | +|-----|---------|----------| +| Recherche + OAuth | `screens/search_screen.dart` | Classe ```14:19:swiftyCompanion/swifty_companion/lib/screens/search_screen.dart``` | +| Profil | `screens/profile_screen.dart` | `fetchProfile` ```37:38:swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart``` | -### Couche services (flux données) +### Chaîne d’appels ```text SearchScreen / ProfileScreen - ↓ -IntraUserService (recherche) IntraProfileService (profil) - ↓ ↓ - intra_http.dart (GET + gestion 401 / 429) - ↓ - IntraAuthService.instance (un seul access + refresh) + → IntraUserService / IntraProfileService + → intraApiGet (401, 429) + → IntraAuthService.instance ``` -| Fichier | Responsabilité | -|---------|----------------| -| `config/intra_config.dart` | URLs API v2 + lecture `.env` | -| `services/intra_auth_service.dart` | OAuth, headers Bearer, refresh | -| `services/intra_user_service.dart` | `GET /users?filter[login]=` | -| `services/intra_profile_service.dart` | User + skills (cursus) + `projects_users` | -| `services/intra_api_exception.dart` | Erreurs métier → messages UI | -| `theme/swifty_ui.dart` | Scroll, largeurs max, AppBar commune | +| Service | Fichier | Lignes utiles | +|---------|---------|---------------| +| Profil agrégé | `intra_profile_service.dart` | `fetchProfile` ~71 ; skills ~173 ; projets ~222 ; cache TTL ~65-66 | +| Erreurs typées | `intra_api_exception.dart` | fichier entier | +| Layout | `swifty_ui.dart` | `wideBreakpoint` ```13:13:swiftyCompanion/swifty_companion/lib/theme/swifty_ui.dart``` ; profil 2 col. ```103:103:swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart``` | -### Un seul token OAuth (critère sujet) +### Photo de profil (critère sujet n°4) — à savoir expliquer -1. **Une fois** : `authorizeAndExchangeCode` → access (+ refresh en bonus). -2. **Ensuite** : chaque appel API utilise `authHeaders()` — **pas** de nouvelle fenêtre OAuth par recherche. +Le PDF demande **quatre détails + profile picture**. L’encadré confidentialité permet de ne pas afficher une donnée **si l’API ne la fournit pas** ; ton code **tente** d’afficher la photo dès qu’une URL existe. -Singleton : `IntraAuthService.instance`. - ---- - -## 4. Démo guidée — commandes et parcours (~4–6 min) - -### 4.1 Terminal — ce que fait chaque commande - -Toujours depuis le package Flutter : - -```bash -cd ~/chemin/vers/swiftyCompanion/swifty_companion -``` - -| Commande | À dire à l’oral | -|----------|-----------------| -| `flutter doctor` | Vérifie SDK Flutter, Android toolchain, device branché — preuve que l’environnement est prêt. | -| `flutter pub get` | Télécharge les dépendances du `pubspec.yaml` (`flutter_appauth`, `http`, `dotenv`, …). | -| `flutter analyze` | Analyse statique Dart — pas d’erreurs bloquantes. | -| `flutter test` | Lance les tests unitaires / widget (`test/widget_test.dart`, `test/intra_oauth_tokens_test.dart` pour le bonus). | -| `flutter devices` | Liste émulateur ou téléphone — on note l’id pour `-d`. | -| `flutter run -d ` | Compile, installe et lance l’app en mode debug sur l’appareil. | - -**Ordre recommandé devant l’évaluateur :** `flutter test` (rapide, visible) → `flutter run` si l’app n’est pas déjà lancée. - -### 4.2 Parcours écran par écran - -| Étape | Action | Ce que tu commentes | -|-------|--------|---------------------| -| 1 | Lancer l’app | Si `.env` manquant : message explicite sur l’écran recherche (`IntraConfig.isConfigured`). | -| 2 | **Se connecter avec 42** | Flux OAuth : navigateur / Custom Tab → redirect vers l’app → token stocké en mémoire. | -| 3 | Login valide (ex. un camarade ou toi) | `GET` filtre login → si trouvé, `Navigator.push` vers profil. | -| 4 | **Profil** | Photo, ≥ 4 champs, carte skills (niveau + %), liste projets **finished** et **failed**. | -| 5 | **Retour** (flèche) | Retour vue 1 — **session conservée** (pas besoin de reconnecter). | -| 6 | Deuxième recherche | Prouve le token unique. | -| 7 | Login invalide `zzzzinvalid` | Message clair, pas de crash, pas d’ouverture profil vide. | -| 8 | Mode avion (recherche ou profil) | Erreur réseau ; sur profil, bouton **Réessayer**. | -| 9 | (Option) Rotation / grand écran | Profil en 2 colonnes si large (`LayoutBuilder`, breakpoint 720 px). | - -**Login de démo :** prévoir un login qui a des skills et au moins un projet failed visible. - ---- - -## 5. Critères obligatoires — réponses « où c’est » (~2 min si questions) - -Référence complète : [guide-soutenance-realisation.md §3](guide-soutenance-realisation.md#3-chapitre-v1--partie-obligatoire-9-critères). - -| # | Critère | Réponse courte + fichier | -|---|---------|---------------------------| -| 1 | 2 vues | `search_screen.dart` + `profile_screen.dart` | -| 2 | Toutes les erreurs | Exceptions dans `intra_api_exception.dart` ; catch dans services + UI (`search_screen`, `profile_screen` `_ErrorBody`) | -| 3 | Profil seulement si login existe | Pas de navigation si liste users vide — `IntraUserNotFoundException` | -| 4 | ≥ 4 infos + photo | `_ProfileHeader`, `_DetailsCard` — email, phone, location, wallet, niveau cursus, … | -| 5 | Skills niveau + % | Depuis `cursus_users[].skills` (pas `/experiences`) — `IntraProfileService._parseSkillsFromCursus` | -| 6 | Projets dont failed | `projects_users` — statuts `finished` et `failed` — scope `projects` | -| 7 | Retour vue 1 | `Navigator.pop` + AppBar retour | -| 8 | Layout moderne / flexible | `swifty_ui.dart` — scroll, `searchMaxWidth`, 2 colonnes profil | -| 9 | Un token, pas un par requête | `IntraAuthService.instance` + `intra_http.dart` | - -**Champs `—` sur le profil :** l’API masque certaines données selon la confidentialité — conforme au sujet. - ---- - -## 6. Erreurs et cas limites — quoi montrer - -| Cas | Démo | Message / comportement attendu | -|-----|------|--------------------------------| -| Login vide | Rechercher sans texte | « Saisis un login » | -| Login inconnu | `zzzzinvalid` | Pas de profil | -| Non connecté | (si évaluateur demande) tenter recherche sans OAuth | Invitation à se connecter | -| Réseau | Mode avion | Message réseau | -| 403 scopes | (expliquer) | Scopes `public` + `projects` sur l’app Intra | -| 429 rate limit | (souvent pas en démo) | Retry dans `intra_http.dart` + cache profil 2 min | -| 401 session | Après refresh impossible | « Session expirée… » — voir bonus | -| Erreur chargement profil | (si réseau flaky) | Écran erreur + **Réessayer** | - -Tu n’es pas obligé de tout rejouer : **2–3 cas** bien choisis suffisent souvent. - ---- - -## 7. Bonus refresh token (~1–2 min) - -### Flux à expliquer +#### Deux étapes (API JSON ≠ fichier image) ```text -Login → access + refresh en mémoire -GET API → 401 Unauthorized - → POST /oauth/token (grant_type=refresh_token) - → nouveau access - → même GET rejoué une fois -Si refresh échoue → IntraUnauthorizedException, l’utilisateur se reconnecte +GET /v2/users/{login} → JSON avec "image": { "link", "versions": { large, medium, … } } + ↓ +GET https://cdn.intra.42.fr/users/…/….jpg → bytes JPEG (hébergement CDN) ``` -| Fichier | Rôle | -|---------|------| -| `intra_auth_service.dart` | `refreshAccessToken()`, `_refreshInFlight` | -| `intra_http.dart` | `intraApiGet` avec retry après refresh | -| `intra_oauth_tokens.dart` | Parse JSON token — testé dans `intra_oauth_tokens_test.dart` | +| Étape | Qui | Code | +|-------|-----|------| +| 1. Lire l’URL dans le JSON | `ProfileScreen._imageUrl` | ```65:83:swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart``` | +| 2. Télécharger l’image | `_IntraProfileAvatar._load` | ```222:236:swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart``` | +| 3. Header Bearer (même access token) | `IntraAuthService.cdnImageHeaders` | ```128:136:swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart``` | +| 4. Affichage | `CircleAvatar` + `MemoryImage` | ```244:251:swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart``` | +| En-tête profil | `_ProfileHeader` passe `imageUrl` | cherche `_ProfileHeader` dans `profile_screen.dart` | -**Limite assumée :** tokens en **mémoire** — kill app = nouvelle connexion OAuth (acceptable si tu le dis). +**Pourquoi pas `NetworkImage(url)` seul ?** Le CDN 42 renvoie souvent **403** sans `Authorization: Bearer`. Ce n’est **pas** un nouveau OAuth : c’est le **même** access token que pour `intraApiGet`. -### Démo DEBUG (build debug uniquement) +**Pourquoi pas `authHeaders()` sur l’image ?** Ce helper envoie `Accept: application/json`, inadapté à un JPEG — d’où `cdnImageHeaders()` (Bearer seul). -1. Connexion OK, une recherche OK. -2. Bouton **« DEBUG : simuler token expiré »** sur `SearchScreen` (`kDebugMode` — absent en release). -3. **Rechercher** à nouveau → refresh silencieux → profil sans repasser par le navigateur. +#### Script oral photo (~30 s) -**Si évaluateur en release :** montrer `flutter test` sur `intra_oauth_tokens_test.dart` + expliquer le flux §7. +> « Le sujet demande la photo de profil. L’API me donne une URL sur `cdn.intra.42.fr` dans le champ `image`. Je parse d’abord `link` puis les `versions`. Ensuite je télécharge le fichier avec le même access token que pour l’API, via `cdnImageHeaders`. Si ça échoue ou s’il n’y a pas d’URL, j’affiche l’initiale du login — c’est prévu par l’encadré confidentialité du sujet. » -**Bouton DEBUG au rendu :** **conservé** — visible uniquement en build debug (`kDebugMode`), utile pour prouver le bonus refresh ; absent en release. Voir [fiche-depot-soutenance.md §8](fiche-depot-soutenance.md). - ---- - -## 8. Questions fréquentes — réponses courtes +#### FAQ correcteur | Question | Réponse | |----------|---------| -| Pourquoi pas `/experiences` pour les skills ? | Souvent **403** ; le sujet permet les données user / cursus — skills dans `cursus_users`. | -| Pourquoi `projects` dans les scopes ? | Endpoint `projects_users` exige ce scope. | -| Où est le redirect Android ? | `AndroidManifest.xml` — `RedirectUriReceiverActivity` ; `appAuthRedirectScheme` dans `build.gradle.kts`. | -| Pourquoi des requêtes séquentielles ? | Éviter **429** Too Many Requests sur l’API 42. | -| Cache profil ? | ~2 minutes en mémoire dans `IntraProfileService` — moins de hits API. | -| Différence dépôt perso / école ? | Perso : `docs/` + travail ; école : **racine** = contenu de `swiftyCompanion/` seul. | -| Où est la traduction du sujet ? | Dépôt perso `docs/sujets/` — pas dans Vogosphere. | -| Tests ? | `widget_test` démarre l’app ; bonus : parse token OAuth. | -| WSL vs téléphone ? | OAuth fiable sur **device/émulateur Android** ; voir stratégie VM école dans la piscine mobile. | -| Que faire si SECRET exposé ? | Régénérer sur l’Intra, mettre à jour `.env` local, vérifier l’historique Git. | +| Un token par image ? | **Non** — réutilisation du access token déjà obtenu au login. | +| Régénérer l’app OAuth pour la photo ? | **Non** — la photo dépend du JSON + CDN, pas du SECRET (sauf si OAuth cassé). | +| `public` suffit pour la photo ? | **Oui** — la photo est dans les données user publiques. | --- -## 9. Script condensé (une page — à réciter) +## 6. Démo guidée — terminal et parcours -1. **Intro** — Flutter, OAuth une fois, 2 vues, `.env` hors Git. -2. **`flutter test`** — vert. -3. **Connexion 42** — une fois. -4. **Recherche OK** → profil (photo, champs, skills %, failed). -5. **Retour** → 2ᵉ recherche sans reconnecter. -6. **Login faux** + **mode avion**. -7. **Bonus** — DEBUG token expiré → recherche OK. -8. **Rappel** — 9 critères, un token, `public`+`projects`, `.env.example` dans le repo. +### Commandes (depuis `swiftyCompanion/swifty_companion/`) + +| Commande | Oral | +|----------|------| +| `flutter pub get` | Dépendances | +| `flutter analyze` | *(orthographe anglaise)* analyse statique | +| `flutter test` | `test/widget_test.dart` + `intra_oauth_tokens_test.dart` | +| `flutter devices` / `flutter run -d …` | Device + lancement | + +### Parcours écran + +| Étape | Code à citer si demandé | +|-------|-------------------------| +| Connexion | `_connect` → `signIn` (§3) | +| Navigation profil | ```91:94:swiftyCompanion/swifty_companion/lib/screens/search_screen.dart``` | +| Erreur profil + Réessayer | `_ErrorBody` dans `profile_screen.dart` | +| Login inconnu | `IntraUserNotFoundException` dans `intra_user_service.dart` | +| Déconnexion | `_disconnect` → `signOut` ```74:79:swiftyCompanion/swifty_companion/lib/screens/search_screen.dart``` | --- -## 10. Liens utiles (perso) +## 7. Critères obligatoires — repères code + +| # | Critère | Où montrer | +|---|---------|------------| +| 1 | 2 vues | `search_screen.dart` + `profile_screen.dart` | +| 2 | Erreurs | `intra_api_exception.dart` ; catches `search_screen` / `profile_screen` | +| 3 | Profil si login existe | Liste vide → exception, pas de `Navigator.push` — `intra_user_service.dart` ~53-55 | +| 4 | ≥4 champs + photo | `_DetailsCard` + `_imageUrl` / `_IntraProfileAvatar` — `profile_screen.dart` (§5 photo) | +| 5 | Skills niveau + % | `_parseSkillsFromCursus` ```173:207:swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart``` | +| 6 | Projets + failed | `_parseProjects` ```222:246:swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart``` | +| 7 | Retour vue 1 | `showBack: true` sur AppBar profil ; `Navigator.pop` implicite | +| 8 | Layout flexible | `ResponsiveScrollBody`, `LayoutBuilder` wide 720px | +| 9 | Un token | `IntraAuthService.instance` ; pas de `signIn` dans `_search` | + +**Champs `—` :** confidentialité OAuth — conforme sujet. + +--- + +## 8. Erreurs — quoi montrer + +| Cas | Code / message | +|-----|----------------| +| Login vide | `intra_user_service.dart` ~20-22 | +| Non connecté | `IntraNotAuthenticatedException` | +| Réseau | `IntraNetworkException` dans user/profile services | +| 403 scopes | `IntraForbiddenException` | +| 429 | Retry ```27:32:swiftyCompanion/swifty_companion/lib/services/intra_http.dart``` | +| Session morte | `IntraUnauthorizedException` après échec refresh | + +--- + +## 9. Questions fréquentes + +| Question | Réponse + code | +|----------|----------------| +| Pourquoi pas `/experiences` ? | Skills depuis `cursus_users` — `_parseSkillsFromCursus` | +| Pourquoi scope `projects` ? | `projects_users` ; sinon 403 | +| Pourquoi le bouton DEBUG ? | Démo du bonus refresh en debug — §4 | +| Régénérer SECRET pour la photo ? | **Non** — voir §5 photo (CDN + Bearer) | +| Comment charge-t-on la photo ? | `_imageUrl` puis `_IntraProfileAvatar` + `cdnImageHeaders` | +| Tests bonus ? | `test/intra_oauth_tokens_test.dart` | + +--- + +## 10. Script condensé (1 page) + +1. Intro — Flutter, OAuth une fois, 2 vues, `.env` hors Git (`main.dart`, `intra_config.dart`). +2. `flutter test`. +3. Connexion — `signIn` + `AndroidManifest` redirect. +4. Recherche → profil — `intra_user_service`, `intra_profile_service`. +5. Retour → 2ᵉ recherche (même token). +6. Erreurs — login faux, réseau. +7. Bonus — expliquer access/refresh ; option DEBUG → `debugInvalidateAccessToken` + `intra_http` 401. +8. Rappel — 9 critères, scopes, `.env.example` au rendu. + +--- + +## 11. Liens doc perso | Document | Usage | |----------|--------| -| [guide-soutenance-realisation.md](guide-soutenance-realisation.md) | Tableau sujet ↔ fichiers | -| [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) | Checklist peer-eval | -| [etape-07-bonus-token.md](etape-07-bonus-token.md) | Détail refresh | -| [swiftyCompanion/README.md](../../swiftyCompanion/README.md) | README correcteurs (rendu) | +| [guide-soutenance-realisation.md](guide-soutenance-realisation.md) | Tableau sujet ↔ fichiers détaillé | +| [etape-07-bonus-token.md](etape-07-bonus-token.md) | Refresh approfondi | +| [fiche-depot-soutenance.md](fiche-depot-soutenance.md) | DEBUG §8, Vogosphere | +| [swiftyCompanion/README.md](../../swiftyCompanion/README.md) | README correcteurs | --- -*Aligné sujet Swifty Companion v4 — lot 3 livré.* +*Dernière mise à jour : photo CDN (Bearer), commentaires code, guide + README rendu.* diff --git a/docs/pedagogie/guide-soutenance-realisation.md b/docs/pedagogie/guide-soutenance-realisation.md index 2e9784e..99c6ac0 100644 --- a/docs/pedagogie/guide-soutenance-realisation.md +++ b/docs/pedagogie/guide-soutenance-realisation.md @@ -77,7 +77,7 @@ | Donnée affichée | Source API | Fichier UI | |-----------------|------------|------------| | **Login** | `user['login']` | AppBar + en-tête profil — `profile_screen.dart` | -| **Photo** | `image.link` ou `image_url` | `CircleAvatar` + `NetworkImage` — `_ProfileHeader` | +| **Photo** | `image.link`, `image.versions`, `image_url` | `_imageUrl` + `_IntraProfileAvatar` (`http.get` + Bearer via `cdnImageHeaders`) — `profile_screen.dart` | | **Email** | `user['email']` | `_DetailsCard` | | **Téléphone** | `user['phone']` | `_DetailsCard` | | **Niveau cursus** | `cursus_users` (slug `42`) | `IntraProfileService` + `_DetailsCard` | diff --git a/swiftyCompanion/README.md b/swiftyCompanion/README.md index 7ca2ca6..6c0f05b 100644 --- a/swiftyCompanion/README.md +++ b/swiftyCompanion/README.md @@ -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'),