3 Commits

Author SHA1 Message Date
07feda8d3f notemethode 2026-10-23 09:30:32 +02:00
d8f2235e87 brancheok 2026-10-21 12:02:55 +02:00
a6ab13ee2f 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>
2026-10-21 10:38:16 +02:00
10 changed files with 772 additions and 199 deletions

View File

@@ -15,6 +15,7 @@ Documentation du projet, sur le modèle de [piscine_mobile/docs](../piscine_mobi
| Document | Lien |
|----------|------|
| **Méthode pour un prochain projet** | [methode-demarrage-projet-ecole.md](methode-demarrage-projet-ecole.md) |
| **Reprise / état du projet** | [pedagogie/00-reprise-et-etat.md](pedagogie/00-reprise-et-etat.md) |
| Plan des étapes | [pedagogie/README.md](pedagogie/README.md) |

View File

@@ -0,0 +1,193 @@
# Méthode — démarrer un projet école
Consigne à donner à Cursor au **premier message** d’un nouveau projet 42.
Elle reprend la méthode déjà utilisée sur la piscine mobile, Swifty Companion et Tokenizer.
Copier ce fichier dans `docs/` du nouveau dépôt, ou l’ouvrir dans le chat et demander : « mets en place la structure décrite dans ce document, sans coder la fonctionnalité ».
---
## 1. Deux espaces, un seul dossier de travail
Tout vit dans **un dépôt perso** (Gitea / Git privé). Le correcteur ne voit **jamais** ce dépôt entier.
| Espace | Chemin | Qui le lit | Git école |
|--------|--------|------------|-----------|
| **Travail** | racine du dépôt : `README.md`, `docs/` | Toi + Cursor | Non |
| **Rendu** | un sous-dossier dédié, ex. `swiftyCompanion/`, `tokenizerRendu/`, `mobileModule01/` | Le correcteur, une fois copié à la racine Intra | Oui — **ce contenu devient la racine** du dépôt Vogosphere |
Règle : on **code dans le paquet de rendu**. On **explique dans `docs/`**.
Le README à la racine du dépôt de travail dit en une page : où est le sujet, où est la pédagogie, où est le paquet école, et comment pousser plus tard. Il ne remplace pas le README du rendu.
---
## 2. Ordre de démarrage (avant le code métier)
Cursor crée l’arborescence, extrait le sujet, rédige le cadrage. Il n’implémente pas l’app tant que le cadrage (section 4) n’est pas relu.
```text
<projet>/
README.md # dépôt de travail
.gitignore # .env, builds, .git imbriqué du rendu
docs/
README.md # index
methode-demarrage-projet-ecole.md # cette consigne, recopiée
sujets/
README.md
<slug>.md # traduction FR fidèle + visuels décrits
originaux/
<slug>_subject.pdf # PDF Intra, renommé (pas en.subject.pdf)
captures/ # images extraites du PDF s’il y en a
pedagogie/
README.md # feuille de route (table des étapes)
00-reprise-et-etat.md # où on en est, prochaine action
cahier-des-charges.md # exigences du sujet → critère testable
architecture.md # modules, flux, données — calé sur le sujet
ui.md # seulement si le sujet a des écrans ou maquettes
protocole-test-manuel.md # checklist device / démo, cases vides
fiche-depot.md # plus tard : comment pousser le rendu
fiche-soutenance-orale.md # plus tard : déroulé oral, reste dans docs/
<paquetRendu>/
README.md # doc école (voir section 6)
.gitignore
.env.example # seulement si le sujet a des secrets
… code …
```
Nom du paquet de rendu : celui du sujet, ou un nom explicite défendable à l’oral. Pas de deuxième copie du code à la racine du dépôt de travail.
---
## 3. Extraction du sujet (première tâche)
1. Déposer le PDF Intra dans `docs/sujets/originaux/`, renommé (`swifty_companion_subject.pdf`, pas `en.subject.pdf`).
2. Extraire le texte **et** les figures (maquettes, schémas, arborescence imposée) dans `docs/sujets/captures/` quand le PDF en contient.
3. Rédiger `docs/sujets/<slug>.md` :
- traduction française **fidèle** (le PDF reste la source si un doute apparaît) ;
- obligatoire / bonus / interdit, chacun dans sa section ;
- arborescence de rendu si le sujet en impose une ;
- messages, noms de champs, libellés : **recopiés tels quels** (souvent en anglais).
4. `docs/sujets/README.md` pointe le markdown et le PDF.
Interdit d’inventer une fonctionnalité, un écran ou une techno absents du sujet pour « améliorer » le projet. Un bonus du PDF se fait après la partie obligatoire, et se note comme bonus dans le cahier des charges.
---
## 4. Doc de dev (cadrage)
Tout ceci reste dans `docs/pedagogie/`. Aucun de ces fichiers ne part sur Vogosphere.
### `cahier-des-charges.md`
Grille de conformité. Une ligne par exigence du PDF :
| # | Exigence (citation courte ou renvoi §) | Critère de validation | Hors scope | Statut |
|---|----------------------------------------|------------------------|------------|--------|
La colonne **Hors scope** sert à figer ce qu’on ne fera pas (ex. « pas d’API météo au module 1 », « pas de mainnet »). En cas de doute, on relit le sujet ; on n’élargit pas le périmètre.
### `architecture.md`
Découpage réel du code à venir : entrée, écrans ou commandes, services (API, auth, base), où vivent les secrets, ce qui est partagé entre exercices. Schéma simple (liste ou mermaid) suffit. Chaque bloc renvoie à une exigence du cahier des charges.
### `ui.md` (si le sujet a une interface)
Un écran = une section : ce que montre la maquette, les libellés exacts, navigation, états vides / erreur / chargement **déjà décrits par le sujet**. Les captures vivent dans `docs/sujets/captures/`. On ne redessine pas.
### Feuille de route
`docs/pedagogie/README.md` est la table des étapes. Une étape = un objectif vérifiable (ex. « OAuth + `/v2/me` », « ex00 géoloc »). Statut : à faire / en cours / fait.
`00-reprise-et-etat.md` tient en une page : ce qui est fait, la prochaine étape, les commandes pour relancer. C’est le fichier à ouvrir en reprenant le projet.
Les guides détaillés (`etape-02-….md`, `module-01/ex00-guide.md`) s’écrivent **quand l’étape démarre**, pas tous d’avance.
### Rituel de lot
Pour chaque lot (cadrage, README école, fiche orale, commentaires) : Cursor décrit ce qu’il va faire → tu valides → il exécute → la case est cochée dans `00-plan` ou la feuille de route. Pas de gros chantier non relu.
---
## 5. Où coder
- Uniquement dans `<paquetRendu>/`.
- Commentaires de code en **français**. Les chaînes imposées par le sujet restent dans la langue du PDF.
- Secrets : `.env` local, listé dans `.gitignore`, jamais commité. `.env.example` versionné, sans valeur réelle. Le sujet qui punit un secret public (OAuth, clé API) est rappelé dans le README école.
- Pas de lien « pour aller plus vite » vers un autre dépôt école. La piscine peut servir de **référence perso** dans `docs/`, pas dans le paquet rendu.
---
## 6. Doc école — indépendante en fin de projet
Le README de `<paquetRendu>/` est le seul mode d’emploi du correcteur. Il contient : prérequis, installation, variables (via `.env.example`), commandes `analyze` / `test` / `run`, ce qu’il faut montrer (exercices ou écrans), et les messages d’erreur attendus s’ils sont au sujet.
**Pendant la construction**, un lien vers `docs/pedagogie/` est toléré le temps de rédiger le README (pour ne pas réécrire deux fois). **Dès que le comportement est stable**, ces liens sont retirés.
Clôture, avant branche ou push école :
```bash
# depuis le paquet de rendu — doit rester vide
grep -RIn --include='*.md' -E 'docs/pedagogie|/home/|piscine_mobile|tokenizer/docs' .
```
Le correcteur clone le dépôt école, ouvre le README à **sa** racine, et lance les commandes **depuis ce dossier**. On vérifie la même chose en local :
```bash
cd <paquetRendu>
# commandes du README, sans sortir vers la racine du dépôt de travail
```
Checklist device : `docs/pedagogie/protocole-test-manuel.md` (cases à cocher). Le README école reprend les critères utiles au correcteur, sans renvoyer vers ce protocole.
---
## 7. Git — travail et rendu
Développer sur **`main`** du dépôt perso. La doc `docs/` se commit **là**. La perdre sur une branche de rendu, c’est la perdre au checkout.
Deux formes déjà utilisées ; en choisir **une** au cadrage et l’écrire dans `fiche-depot.md`.
**A — Paquet imbriqué** (Swifty, Tokenizer). Quand le rendu est prêt : `git init` dans `<paquetRendu>/` (ou copie vers un clone Vogosphere vide). Le `.git` imbriqué est ignoré par le dépôt de travail. On ne pousse pas `docs/`.
**B — Branche snapshot** (piscine, un module = un dépôt). Quand le module est validé sur `main`, créer `rendu-…` qui ne garde que le `.gitignore` racine et le dossier du module. Si `main` change ensuite : **supprimer et recréer** la branche. Pas de merge de `main` dedans.
Dans les deux cas, la fiche orale et les guides restent sur `main` / `docs/`.
---
## 8. Préparation soutenance (après le code)
Dans l’ordre, chacun validé avant le suivant :
| Lot | Livrable | Où |
|-----|----------|----|
| README autonome | plus aucun lien vers `docs/` | `<paquetRendu>/README.md` |
| Fiche orale | déroulé, commandes, démo, questions probables, renvois au code | `docs/pedagogie/` seulement |
| Commentaires | français, sur le code du rendu | `<paquetRendu>/` |
| Dépôt école | `fiche-depot.md` exécuté ; re-test **depuis** le paquet rendu | Vogosphere |
`analyze` et `test` doivent passer sur cette copie « comme à l’école » avant le push.
---
## 9. Consigne courte à coller dans Cursor
```text
Lis docs/methode-demarrage-projet-ecole.md et applique-la à ce dépôt.
1. Range le PDF dans docs/sujets/originaux/ et produis la traduction fidèle
docs/sujets/<slug>.md (obligatoire, bonus, interdit, libellés exacts,
captures si le PDF a des visuels).
2. Crée le cadrage dans docs/pedagogie/ : reprise, cahier des charges
(une exigence = une ligne testable + hors scope), architecture,
feuille de route, ui.md seulement si le sujet a une interface,
protocole de test manuel vide.
3. Crée le paquet de rendu avec README, .gitignore et .env.example
si des secrets existent. Le README école peut linker docs/ le temps
du développement ; il sera nettoyé avant le push école.
4. Ne code pas la fonctionnalité tant que je n’ai pas validé le cadrage.
5. Ensuite, une étape de la feuille de route à la fois, code uniquement
dans le paquet de rendu, commentaires en français, aucun ajout hors sujet.
6. Réponds en français. Décris le lot, attends ma validation, puis exécute.
```

View File

@@ -2,6 +2,8 @@
Parcours pour construire le projet **étape par étape**.
Pour un **nouveau** projet école, la consigne à donner à Cursor est [methode-demarrage-projet-ecole.md](../methode-demarrage-projet-ecole.md).
## Reprise rapide
→ **[00-reprise-et-etat.md](00-reprise-et-etat.md)** — état du projet.
@@ -12,6 +14,8 @@ Parcours pour construire le projet **étape par étape**.
→ **[fiche-soutenance-orale.md](fiche-soutenance-orale.md)** — déroulé soutenance, commandes, démo, FAQ.
→ **[protocole-test-manuel-device.md](protocole-test-manuel-device.md)** — checklist téléphone + FAQ (hidden, cache, logout, skills).
→ **[guide-soutenance-realisation.md](guide-soutenance-realisation.md)** — cartographie sujet ↔ code pour la correction.
## Prérequis

View File

@@ -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`) :

View File

@@ -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 <device>` | 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.*

View File

@@ -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` |

View File

@@ -0,0 +1,200 @@
# Protocole de test manuel sur téléphone — Swifty Companion
> **Objectif :** valider la partie obligatoire (et le bonus) **sur device** avant de figer la branche `soutenance` et le rendu Vogosphere.
> **Code :** `swiftyCompanion/swifty_companion/` · **Branche travail :** `main` · **Branche portable / rendu :** `soutenance`.
**Prérequis PC :**
```bash
cd swiftyCompanion/swifty_companion
flutter pub get && flutter analyze && flutter test
flutter devices
flutter run -d <id_téléphone>
```
`.env` dans `swifty_companion/.env` · OAuth Intra : scopes **public** + **projects** · redirect = `com.example.swiftycompanion://callback`.
---
## Réponses aux points soulevés (à lire avant de cocher)
### A — Déconnexion (icône logout) vs annulation OAuth
| Action | Comportement actuel | Faut-il un message « connexion annulée » ? |
|--------|---------------------|---------------------------------------------|
| **Annuler** dans le navigateur OAuth (pendant « Se connecter avec 42 ») | Message **« Connexion annulée »** | Déjà géré — `search_screen.dart` (`FlutterAppAuthUserCancelledException`). |
| **Déconnexion** (icône logout en haut à droite quand connecté) | Retour à l’écran **« Connecte-toi »** sans message | **Non obligatoire** au sujet. C’est une **déconnexion volontaire**, pas une annulation OAuth. Optionnel : SnackBar « Déconnecté » — pas requis pour valider. |
**Test A3** du protocole = annuler **pendant** le flux navigateur, pas appuyer sur logout.
### B3 — Champs profil : `hidden`, vide, ≥ 4 détails
Le sujet demande **au moins quatre détails** parmi login, email, mobile, level, location, wallet, evaluations, etc. **+ photo**.
| Ce que tu vois | Conforme ? |
|----------------|------------|
| Email affiché | Oui — compte comme 1 détail |
| Téléphone = **`hidden`** | Oui — c’est la **valeur renvoyée par l’API** (confidentialité), pas un bug |
| Niveau cursus affiché | Oui |
| Localisation vide → **`—`** | Oui — souvent pas de poste connecté / pas sur PC école ; encadré sujet confidentialité |
| Wallet (points) | Oui |
| **Login** + **photo** (en-tête) | Comptent aussi pour le critère |
Tu as **plus de 4 lignes utiles** même si certaines sont `hidden` ou `—`. **Pas besoin d’ajouter** d’autres champs (evaluation_points, etc.) sauf choix perso.
Autres champs possibles dans l’API (non requis) : `displayname`, `correction_point`, `pool_month`, … — le sujet ne impose pas de tout afficher.
### B4 — « Niveau entier » pour les skills
L’API envoie un niveau **décimal** (ex. `8.42`).
| Affichage app | Signification |
|---------------|----------------|
| **Niveau 8** | Partie **entière** (`floor`) — c’est le « niveau entier » du sujet |
| **(42 %)** | Partie décimale × 100 — le **pourcentage** du sujet |
Exemple ligne UI : `Algorithms — niveau 8 (42 %)`.
Code : `IntraProfileService._skillFromLevel` · affichage `_SkillsCard` dans `profile_screen.dart`.
Plusieurs skills affichés = **mieux** ; le sujet exige le **format** niveau + % sur les skills montrés.
### C5 — Profil ouvert + réseau coupé (cache)
| Comportement | Explication |
|--------------|-------------|
| Profil **déjà chargé** reste visible sans réseau | Cache mémoire ~2 min (`IntraProfileService`) + données déjà en RAM — **normal** |
| Erreur réseau au **retour** puis nouvelle recherche | Couvre le critère « erreur réseau » |
| Erreur + **Réessayer** si chargement profil échoue | Déjà implémenté (`_ErrorBody`) |
Le sujet **n’exige pas** un rafraîchissement automatique du profil quand le réseau tombe. Test **C5 = optionnel** (amélioration possible plus tard, pas bloquant soutenance).
### F — Après déconnexion
Comportement attendu et **correct** :
- Plus de session → écran **connexion OAuth** uniquement ;
- **Pas** de recherche tant que tu ne t’es pas reconnecté (`IntraNotAuthenticatedException` si tu forçais une recherche — l’UI masque le formulaire tant que déconnecté).
---
## Grille de test — à cocher sur le téléphone
Légende : **O** = OK · **N** = échec · **—** = non testé / optionnel
### Préparation
| ID | Vérification | O / N / — |
|----|--------------|-----------|
| P1 | `flutter test` vert sur la branche testée | |
| P2 | `.env` présent, app démarre sans erreur config | |
| P3 | App installée via `flutter run` (debug) | |
### A — OAuth
| ID | Action | Résultat attendu | O / N / — |
|----|--------|------------------|-----------|
| A1 | **Se connecter avec 42** (succès) | Session affichée, formulaire recherche visible | |
| A2 | **Déconnexion** (icône logout) | Écran connexion, **sans** message obligatoire | |
| A3 | Reconnecter, lancer OAuth puis **annuler** dans le navigateur | Message **« Connexion annulée »** (pas confondre avec A2) | |
| A4 | Reconnexion réussie | Session OK | |
### B — Parcours nominal
| ID | Action | Résultat attendu | O / N / — |
|----|--------|------------------|-----------|
| B1 | Recherche login valide | Écran profil | |
| B2 | Photo | Image visible (ou initiale si API sans URL — expliquer à l’oral) | |
| B3 | Carte informations | ≥ 4 lignes (email, tel même `hidden`, niveau, wallet, etc.) | |
| B4 | Skills | Au moins une ligne `niveau X (Y %)` | |
| B5 | Projets | Au moins un validé et idéalement un **failed** | |
| B6 | Retour arrière | Recherche, **toujours connecté** | |
| B7 | 2ᵉ recherche (autre login) | Profil sans nouveau OAuth | |
### C — Erreurs
| ID | Action | Résultat attendu | O / N / — |
|----|--------|------------------|-----------|
| C1 | Recherche login vide | Message « Saisis un login » | |
| C2 | `zzzzinvalid` | Message, pas de profil vide | |
| C3 | Mode avion → **rechercher** | Message réseau | |
| C4 | Réseau OK → recherche à nouveau | Profil OK | |
| C5 | *(Optionnel)* Profil ouvert → couper réseau | Données en cache OK ; pas exigé par le sujet | — |
### D — Layout
| ID | Action | Résultat attendu | O / N / — |
|----|--------|------------------|-----------|
| D1 | Rotation paysage (profil) | Lisible / 2 colonnes si large | |
| D2 | Clavier sur écran recherche | Scroll OK | |
### E — Bonus (optionnel)
| ID | Action | Résultat attendu | O / N / — |
|----|--------|------------------|-----------|
| E1 | DEBUG simuler token expiré | Message debug | |
| E2 | Rechercher ensuite | Profil sans navigateur OAuth | |
### F — Session (complément)
| ID | Action | Résultat attendu | O / N / — |
|----|--------|------------------|-----------|
| F1 | Déconnecté : pas de champ recherche utilisable | Uniquement bouton connexion | |
---
## Validation sujet (après grille)
| # | Critère | Validé si… | O / N |
|---|---------|------------|-------|
| 1 | 2 vues | B1, B6 | |
| 2 | Erreurs | C1–C4 (+ A3) | |
| 3 | Profil si login existe | C2 vs B1 | |
| 4 | 4 détails + photo | B2, B3 | |
| 5 | Skills niveau + % | B4 | |
| 6 | Projets + failed | B5 | |
| 7 | Retour vue 1 | B6 | |
| 8 | Layout | D1 ou D2 | |
| 9 | Un token | B7 | |
| Bonus | Refresh | E1–E2 | |
**Décision :** si tous les **O / N** obligatoires sont **O** → passer à la **mise à jour branche `soutenance`** (voir ci-dessous).
---
## Après validation manuelle — branche `soutenance`
1. `git checkout main` → `git push origin main` (si pas déjà fait).
2. Aligner `main` / `soutenance` si besoin (ex. un seul `.env.example` sur `main`).
3. `git checkout soutenance` → vérifier commit photo (`eaacef0` ou plus récent).
4. **Re-test sur `soutenance` :**
```bash
cd swiftyCompanion/swifty_companion
flutter pub get && flutter analyze && flutter test
flutter run -d <device>
```
5. Refaire **minimum** : A1, B1–B7, C2, B2 (photo).
6. `git push origin soutenance`.
---
## Améliorations **non** bloquantes (ne pas faire avant validation sauf si tu veux)
| Idée | Priorité |
|------|----------|
| SnackBar « Déconnecté » au logout | Cosmétique |
| Pull-to-refresh profil quand réseau revient | Hors sujet |
| Afficher `evaluation_points` | Optionnel sujet |
| Invalider cache profil à la perte réseau | Optionnel |
---
## Liens
- [fiche-soutenance-orale.md](fiche-soutenance-orale.md) — oral + repères code
- [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) — rendu Git
- [guide-soutenance-realisation.md](guide-soutenance-realisation.md) — sujet ↔ fichiers
*Dernière mise à jour : retours test device (logout, hidden, cache profil, skills).*

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é.
**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

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,

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 {
final response = await intraApiGet(
Uri.parse('${IntraConfig.apiBaseUrl}/me'),