From 07f88198c04f8ed9cacb9f32cff4ebf306d61abc Mon Sep 17 00:00:00 2001 From: Ladebeze66 Date: Wed, 30 Sep 2026 08:24:20 +0200 Subject: [PATCH] guidesoutenance --- README.md | 4 +- docs/pedagogie/00-reprise-et-etat.md | 28 +- docs/pedagogie/README.md | 16 +- docs/pedagogie/etape-03-vue-recherche.md | 1 - docs/pedagogie/etape-05-navigation-layout.md | 74 +++++ .../etape-06-checklist-soutenance.md | 118 +++++++ docs/pedagogie/etape-07-bonus-token.md | 93 ++++++ .../pedagogie/guide-soutenance-realisation.md | 288 ++++++++++++++++++ docs/sujets/swifty-companion.md | 2 + swiftyCompanion/README.md | 54 ++-- .../swifty_companion/lib/main.dart | 9 +- .../lib/screens/auth_screen.dart | 139 --------- .../lib/screens/profile_screen.dart | 204 ++++++++----- .../lib/screens/search_screen.dart | 88 +++--- .../lib/services/intra_auth_service.dart | 90 +++++- .../lib/services/intra_http.dart | 18 +- .../lib/services/intra_oauth_tokens.dart | 21 ++ .../lib/services/intra_profile_service.dart | 5 +- .../lib/services/intra_user_service.dart | 5 +- .../swifty_companion/lib/theme/swifty_ui.dart | 109 +++++++ .../test/intra_oauth_tokens_test.dart | 32 ++ 21 files changed, 1067 insertions(+), 331 deletions(-) create mode 100644 docs/pedagogie/etape-05-navigation-layout.md create mode 100644 docs/pedagogie/etape-06-checklist-soutenance.md create mode 100644 docs/pedagogie/etape-07-bonus-token.md create mode 100644 docs/pedagogie/guide-soutenance-realisation.md delete mode 100644 swiftyCompanion/swifty_companion/lib/screens/auth_screen.dart create mode 100644 swiftyCompanion/swifty_companion/lib/services/intra_oauth_tokens.dart create mode 100644 swiftyCompanion/swifty_companion/lib/theme/swifty_ui.dart create mode 100644 swiftyCompanion/swifty_companion/test/intra_oauth_tokens_test.dart diff --git a/README.md b/README.md index 44b5f47..6207da4 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ Tout ce qui concerne correcteurs et soutenance : **[swiftyCompanion/README.md](s ## Reprise du projet -**État actuel :** structure et documentation OK, **pas encore de code Flutter**. +**État actuel :** app Flutter dans `swiftyCompanion/swifty_companion/`, partie obligatoire codée. -→ [docs/pedagogie/00-reprise-et-etat.md](docs/pedagogie/00-reprise-et-etat.md) (arborescence, checklist, **prochaine étape : `flutter create` dans `swiftyCompanion/`**) +→ [docs/pedagogie/00-reprise-et-etat.md](docs/pedagogie/00-reprise-et-etat.md) · **correction :** [guide-soutenance-realisation.md](docs/pedagogie/guide-soutenance-realisation.md) ## Démarrage diff --git a/docs/pedagogie/00-reprise-et-etat.md b/docs/pedagogie/00-reprise-et-etat.md index 046beaa..96ad8ca 100644 --- a/docs/pedagogie/00-reprise-et-etat.md +++ b/docs/pedagogie/00-reprise-et-etat.md @@ -1,6 +1,6 @@ # Reprise du projet — où nous en sommes -> Dernière mise à jour : étape 4 codée (profil complet). +> Dernière mise à jour : étape 7 bonus (refresh token) codée. ## En bref @@ -9,7 +9,7 @@ | **Fait** | Structure dépôt perso, sujet FR, paquet rendu, **projet Flutter** (`swifty_companion/`) | | **Fait** | OAuth + token + `/v2/me` (login connecté) | | **Fait** | Vue 1 recherche + vue 2 profil (skills, projets, ≥ 4 champs) | -| **Prochaine action** | **Étape 5** — retour / layout + tests manuels sur device | +| **Prochaine action** | **Rendu Vogosphere** + test manuel bonus (token invalide) | ## Arborescence actuelle @@ -49,21 +49,23 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso) | **2** | OAuth2 (`IntraAuthService`, `/v2/me`) | ✅ **Validé** | | **3** | Vue 1 : `SearchScreen` + `IntraUserService` + navigation profil | ✅ **Codé** — tester sur téléphone | | **4** | Vue 2 : `IntraProfileService` + profil (skills, projets, champs) | ✅ **Codé** — tester sur téléphone | -| **5** | Retour vue 1 + layout adaptatif (polish) | ⏳ **Prochaine** | -| **6** | Passage checklist [swiftyCompanion/README.md](../../swiftyCompanion/README.md) | ⏳ | -| **7** | Bonus : refresh token à expiration | ⏳ | +| **5** | Retour vue 1 + layout adaptatif (`swifty_ui.dart`) | ✅ **Codé** — [etape-05-navigation-layout.md](etape-05-navigation-layout.md) | +| **6** | Checklist soutenance + rendu école | ✅ **Guide** — [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) | +| **7** | Bonus : refresh token à expiration | ✅ **Codé** — [etape-07-bonus-token.md](etape-07-bonus-token.md) | ## Code applicatif (`swifty_companion/lib/`) | Fichier | Rôle | |---------|------| | `config/intra_config.dart` | `.env`, URLs OAuth / API | -| `services/intra_auth_service.dart` | OAuth + token unique + `GET /v2/me` | +| `services/intra_auth_service.dart` | OAuth + refresh token + `GET /v2/me` | +| `services/intra_oauth_tokens.dart` | Parse réponse `/oauth/token` | | `services/intra_user_service.dart` | `GET /v2/users?filter[login]=` | -| `services/intra_profile_service.dart` | Profil : user, cursus, experiences, projects_users | +| `services/intra_profile_service.dart` | Profil : user, cursus_users, projects_users | | `services/intra_api_exception.dart` | Erreurs métier | | `screens/search_screen.dart` | **Vue 1** | | `screens/profile_screen.dart` | **Vue 2** | +| `theme/swifty_ui.dart` | Layout responsive partagé | | `main.dart` | `home: SearchScreen()` | ## Reprendre le travail (checklist) @@ -82,6 +84,7 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso) | Tu veux… | Fichier | |----------|---------| +| **Préparer la correction (sujet ↔ fichiers)** | **[guide-soutenance-realisation.md](guide-soutenance-realisation.md)** | | Comprendre le sujet | [docs/sujets/swifty-companion.md](../sujets/swifty-companion.md) | | Plan complet des étapes | [docs/pedagogie/README.md](README.md) | | Vue profil (étape 4) | [etape-04-vue-profil.md](etape-04-vue-profil.md) | @@ -95,11 +98,12 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso) | [etape-02-oauth-intra.md](etape-02-oauth-intra.md) | ✅ | | [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | ✅ | | [etape-04-vue-profil.md](etape-04-vue-profil.md) | ✅ | -| `etape-05-navigation-layout.md` | 🔜 | -| `etape-06-bonus-token.md` | 🔜 | +| [etape-05-navigation-layout.md](etape-05-navigation-layout.md) | ✅ | +| [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) | ✅ | +| [etape-07-bonus-token.md](etape-07-bonus-token.md) | ✅ piste bonus | ## Notes pour la session suivante -- Valider étapes 3–4 sur **device réel** (recherche, profil, mode avion sur vue 2). -- Étape 5 : harmoniser marges / contraintes sur les deux écrans si besoin en soutenance. -- Le dépôt perso peut versionner le code dans `swiftyCompanion/` ; le remote **école** pointe uniquement sur ce sous-dossier (voir README racine). +- Partie obligatoire : prête pour peer-eval si la checklist étape 6 est OK sur device. +- `git init` dans `swiftyCompanion/` quand tu pousses sur Vogosphere (voir README racine). +- Bonus refresh token : étape 7 si tu as le temps après rendu. diff --git a/docs/pedagogie/README.md b/docs/pedagogie/README.md index 778f8e9..2cc1ab1 100644 --- a/docs/pedagogie/README.md +++ b/docs/pedagogie/README.md @@ -4,7 +4,9 @@ Parcours pour construire le projet **étape par étape**. ## Reprise rapide -→ **[00-reprise-et-etat.md](00-reprise-et-etat.md)** — état actuel, arborescence, prochaine action (**étape 5 : layout / tests**). +→ **[00-reprise-et-etat.md](00-reprise-et-etat.md)** — état du projet. + +→ **[guide-soutenance-realisation.md](guide-soutenance-realisation.md)** — **cartographie sujet ↔ code** pour la correction (ne rien oublier). ## Prérequis @@ -22,9 +24,9 @@ Base via la [piscine mobile](../../../piscine_mobile/README.md) sur le même dis | **2** | OAuth2 Intra : token + `/v2/me` | ✅ **Fait** — [etape-02-oauth-intra.md](etape-02-oauth-intra.md) | | **3** | **Vue 1** : recherche par login + erreurs | ✅ **Codé** — [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | | **4** | **Vue 2** : profil (≥ 4 champs, skills, projets failed) | ✅ **Codé** — [etape-04-vue-profil.md](etape-04-vue-profil.md) | -| **5** | Navigation retour + layout responsive (polish) | ⏳ **Prochaine** | -| **6** | Tests manuels + checklist peer-eval | ⏳ | -| **7** | **Bonus** : refresh token à l’expiration | ⏳ | +| **5** | Navigation retour + layout responsive | ✅ **Codé** — [etape-05-navigation-layout.md](etape-05-navigation-layout.md) | +| **6** | Tests + checklist peer-eval + rendu Git école | ✅ **Guide** — [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) | +| **7** | **Bonus** : refresh token à l’expiration | ✅ **Codé** — [etape-07-bonus-token.md](etape-07-bonus-token.md) | README correcteurs (dépôt école) : [swiftyCompanion/README.md](../../swiftyCompanion/README.md) @@ -40,7 +42,9 @@ README correcteurs (dépôt école) : [swiftyCompanion/README.md](../../swiftyCo | [etape-02-oauth-intra.md](etape-02-oauth-intra.md) | ✅ Guide étape 2 | | [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | ✅ Guide étape 3 | | [etape-04-vue-profil.md](etape-04-vue-profil.md) | ✅ Guide étape 4 | -| `etape-05-navigation-layout.md` | 🔜 | -| `etape-06-bonus-token.md` | 🔜 | +| [etape-05-navigation-layout.md](etape-05-navigation-layout.md) | ✅ Guide étape 5 | +| [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) | ✅ Guide étape 6 | +| [etape-07-bonus-token.md](etape-07-bonus-token.md) | ✅ Bonus refresh | +| **[guide-soutenance-realisation.md](guide-soutenance-realisation.md)** | ✅ **Guide correction / soutenance** | Demande dans le chat l’étape à ouvrir : le guide correspondant sera rédigé au moment où tu l’attaques. diff --git a/docs/pedagogie/etape-03-vue-recherche.md b/docs/pedagogie/etape-03-vue-recherche.md index 5168faa..b1fe0f7 100644 --- a/docs/pedagogie/etape-03-vue-recherche.md +++ b/docs/pedagogie/etape-03-vue-recherche.md @@ -50,7 +50,6 @@ lib/ │ ├── intra_auth_service.dart # existant │ └── intra_user_service.dart # GET user(s) avec token ├── screens/ -│ ├── auth_screen.dart # option : fusionner ou garder login au démarrage │ ├── search_screen.dart # VUE 1 │ └── profile_screen.dart # VUE 2 (squelette → étape 4) └── main.dart # home → SearchScreen si token OK diff --git a/docs/pedagogie/etape-05-navigation-layout.md b/docs/pedagogie/etape-05-navigation-layout.md new file mode 100644 index 0000000..f41d431 --- /dev/null +++ b/docs/pedagogie/etape-05-navigation-layout.md @@ -0,0 +1,74 @@ +# Étape 5 — Navigation et layout adaptatif + +> **Prérequis :** [étape 4 — profil](etape-04-vue-profil.md) validée sur téléphone (OAuth `public` + `projects`). + +## Objectif sujet + +| Critère | Cette étape | +|---------|-------------| +| Retour vue 1 | `Navigator.push` / `pop` — flèche AppBar sur la vue 2 | +| Layout flexible | Scroll, marges, largeur max, 2 colonnes profil si écran large | +| Lisibilité | Rotation, clavier ouvert, petits écrans sans overflow | + +--- + +## Navigation + +```text +SearchScreen --push--> ProfileScreen +ProfileScreen --pop--> SearchScreen (session OAuth conservée) +``` + +- Pas de `go_router` obligatoire : **Navigator 1.0** suffit. +- La vue 1 garde le token ; une nouvelle recherche ne refait pas OAuth. + +--- + +## Fichiers ajoutés / modifiés + +``` +lib/ +├── theme/ +│ └── swifty_ui.dart # couleur 42, padding, AppBar, scroll responsive +├── screens/ +│ ├── search_screen.dart # formulaire scrollable, centré +│ └── profile_screen.dart # lignes détail adaptatives, UI partagée +└── main.dart # thème Material 3 (seed 42) +``` + +### `swifty_ui.dart` + +| Widget / helper | Rôle | +|-----------------|------| +| `SwiftyUi.appBar` | AppBar turquoise identique sur les 2 vues | +| `ResponsiveScrollBody` | `SingleChildScrollView` + padding + `maxWidth` | +| `ErrorMessage` | Texte d’erreur sélectionnable, centré | +| `LoadingFilledButton` | Bouton avec spinner (connexion / recherche) | + +### Vue recherche + +- Contenu dans `ResponsiveScrollBody` (`maxWidth` ~480 px). +- Évite l’overflow quand le **clavier** est ouvert. + +### Vue profil + +- Breakpoint **720 px** : 1 colonne (téléphone) / 2 colonnes (tablette, paysage). +- `_DetailRow` : label + valeur en **colonne** si la ligne est trop étroite (< 400 px). + +--- + +## Critères « étape 5 terminée » + +| # | Test | +|---|------| +| 1 | Profil → retour → recherche sans déconnexion | +| 2 | Rotation paysage : pas de bande jaune / overflow | +| 3 | Clavier ouvert sur la recherche : boutons accessibles (scroll) | +| 4 | Tablette ou fenêtre large : profil en 2 colonnes lisible | +| 5 | Erreurs toujours visibles (couleur + message lisible) | + +--- + +## Ensuite — étape 6 + +→ [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) : `flutter test`, démo peer-eval, préparation dépôt école. diff --git a/docs/pedagogie/etape-06-checklist-soutenance.md b/docs/pedagogie/etape-06-checklist-soutenance.md new file mode 100644 index 0000000..f4c7156 --- /dev/null +++ b/docs/pedagogie/etape-06-checklist-soutenance.md @@ -0,0 +1,118 @@ +# Étape 6 — Tests, soutenance et rendu école + +> **Prérequis :** étapes 2–5 OK sur device (OAuth `public` + `projects`, recherche, profil, retour, layout). + +## Objectif + +1. Valider la **partie obligatoire** du sujet avant peer-eval. +2. Préparer le **dépôt Vogosphere** (sans `docs/`, sans `.env`). +3. Savoir **quoi montrer** à l’évaluateur et dans quel ordre. + +Référence correcteurs : [swiftyCompanion/README.md](../../swiftyCompanion/README.md). + +--- + +## Commandes avant soutenance + +Depuis le package Flutter : + +```bash +cd ~/projets/42School/swifty_companion/swiftyCompanion/swifty_companion +flutter pub get +flutter analyze +flutter test +flutter doctor +``` + +Sur téléphone (USB ou Wi‑Fi ADB) : + +```bash +flutter devices +flutter run -d +``` + +Après changement de `.env` ou de scopes OAuth : **arrêter** `flutter run` et relancer ; **reconnexion** 42 dans l’app. + +--- + +## Checklist peer-eval (détaillée) + +| # | Test | Comment le montrer | Où c’est géré dans l’app | +|---|------|--------------------|---------------------------| +| 1 | `flutter test` | Terminal sur la machine de dev | `test/widget_test.dart` | +| 2 | Parcours nominal | Connexion → login valide → profil | `SearchScreen` → `ProfileScreen` | +| 3 | Profil complet | Photo, ≥ 4 champs, skills (niveau + %), projets validés **et** failed | `IntraProfileService` + `profile_screen.dart` | +| 4 | Login invalide | `zzzzinvalid` → message, pas de crash | `IntraUserNotFoundException` | +| 5 | Hors ligne | Mode avion sur recherche **ou** sur profil (erreur + réessai) | `IntraNetworkException`, retry profil | +| 6 | Navigation | Retour AppBar → nouvelle recherche, session conservée | `Navigator.pop` | +| 7 | Petit écran / rotation | Paysage, clavier ouvert sur la recherche | `ResponsiveScrollBody`, `swifty_ui.dart` | +| 8 | Bonus | Access invalide → refresh → recherche OK | [étape 7](etape-07-bonus-token.md) | + +**Ordre de démo suggéré :** connexion → profil riche (skills + failed) → login faux → mode avion → retour → `flutter test`. + +--- + +## Correspondance sujet v4 ↔ app + +| Critère sujet | Statut attendu | +|---------------|----------------| +| 2 vues | `SearchScreen` + `ProfileScreen` | +| Erreurs | Login vide, inconnu, réseau, 401, 403, 429 | +| Token unique | `IntraAuthService.instance` partout | +| OAuth Intra | `flutter_appauth` + `.env` | +| Layout adaptatif | Étape 5 (`wideBreakpoint` 720 px) | + +Champs masqués par l’API (`—`) : normal (confidentialité sujet). + +--- + +## Rendu Git école + +### Ce qui part sur Vogosphere + +Racine du dépôt Intra = contenu de **`swiftyCompanion/`** : + +- `README.md`, `.gitignore`, `.env.example` +- `swifty_companion/` (code Flutter) + +### Ce qui ne part **pas** + +- `docs/` (dépôt perso uniquement) +- `.env` (secrets) +- Fichiers de backup type `.env copy` + +### Vérifier avant push + +```bash +cd ~/projets/42School/swifty_companion/swiftyCompanion +git status # si repo école initialisé ici +``` + +- Aucun `.env` listé +- `REDIRECT_URI` dans `.env.example` = URI enregistrée sur l’Intra +- README école à jour pour les correcteurs + +Workflow détaillé : [README racine](../../README.md) (section « Pousser vers l’école »). + +--- + +## Parler en soutenance (30 s) + +> « Swifty Companion : OAuth2 Intra une fois, puis recherche par login. Le profil charge cursus, skills et projects_users avec le même token. Erreurs réseau et API gérées ; layout scrollable et deux colonnes sur grand écran. Secrets dans `.env` local, non versionné. » + +--- + +## Critères « étape 6 terminée » + +| # | Fait ? | +|---|--------| +| 1 | `flutter analyze` + `flutter test` verts | +| 2 | Checklist peer-eval cochée sur device | +| 3 | `.env` absent du futur commit école | +| 4 | Tu sais lancer la démo sans notes | + +--- + +## Ensuite — étape 7 (bonus) + +→ [etape-07-bonus-token.md](etape-07-bonus-token.md) : refresh token à l’expiration (optionnel si obligatoire parfaite). diff --git a/docs/pedagogie/etape-07-bonus-token.md b/docs/pedagogie/etape-07-bonus-token.md new file mode 100644 index 0000000..964e293 --- /dev/null +++ b/docs/pedagogie/etape-07-bonus-token.md @@ -0,0 +1,93 @@ +# Étape 7 — Bonus : refresh token + +> **Prérequis :** partie obligatoire validée ([étape 6](etape-06-checklist-soutenance.md)). + +## Objectif sujet + +Quand l’**access token** expire, l’app **rafraîchit** la session via le **refresh token** et continue à fonctionner **sans** relancer l’écran OAuth à chaque requête. + +--- + +## Implémentation (ce dépôt) + +| Élément | Fichier | +|---------|---------| +| Stockage `access` + `refresh` après login | `intra_auth_service.dart` | +| `POST /oauth/token` `grant_type=refresh_token` | `IntraAuthService.refreshAccessToken()` | +| Un seul refresh en parallèle | `_refreshInFlight` (évite 4 refresh si 4 GET en 401) | +| Sur **401** API → refresh → **rejouer** le GET | `intra_http.dart` → `intraApiGet(..., auth:)` | +| Parsing JSON token | `intra_oauth_tokens.dart` | + +Les scopes OAuth restent `public` + `projects` à la connexion initiale. + +--- + +## Flux + +```text +signIn() → access + refresh en mémoire +GET /v2/users/... → 401 + → refreshAccessToken() → nouveaux tokens + → même GET avec nouveau Bearer +sinon → IntraUnauthorizedException (« Reconnecte-toi ») +``` + +`signOut()` efface les deux tokens. + +**Persistance** (secure storage) : non fait — le bonus du sujet vise surtout l’expiration **pendant** une session ; tu peux l’ajouter plus tard. + +--- + +## Tests automatisés + +```bash +cd swiftyCompanion/swifty_companion +flutter test +``` + +- `test/intra_oauth_tokens_test.dart` — parsing réponse `/oauth/token` +- `test/widget_test.dart` — démarrage app + +--- + +## Test manuel (soutenance bonus) + +1. Connexion 42 → recherche OK. +2. **Simuler** un access token expiré : + - en debug, appeler une recherche après avoir invalidé le token en mémoire **sans** effacer le refresh (voir ci‑dessous), **ou** + - attendre l’expiration réelle (souvent long). +3. Nouvelle recherche → refresh silencieux → profil OK **sans** repasser par le navigateur OAuth. + +### Test sur device (build debug) + +Sur l’écran recherche, bouton **« DEBUG : simuler token expiré »** (`kDebugMode` uniquement — absent en release). + +1. Connexion 42 OK. +2. Appuyer sur le bouton debug. +3. **Rechercher** un login → refresh silencieux → profil OK sans repasser par OAuth. + +En release / soutenance APK release : expliquer le flux ou montrer `flutter test`. + +--- + +## Critères « bonus OK » + +| # | Test | +|---|------| +| 1 | 401 sur API → refresh → requête réussie | +| 2 | Refresh impossible → message « session expirée », pas de crash | +| 3 | `flutter test` vert | + +--- + +## Fichiers modifiés (récap) + +``` +lib/services/ +├── intra_auth_service.dart # refresh + stockage refresh_token +├── intra_http.dart # 401 → refresh → retry +├── intra_oauth_tokens.dart # parse JSON token +└── … (user / profile passent auth à intraApiGet) + +test/intra_oauth_tokens_test.dart +``` diff --git a/docs/pedagogie/guide-soutenance-realisation.md b/docs/pedagogie/guide-soutenance-realisation.md new file mode 100644 index 0000000..2afa9bf --- /dev/null +++ b/docs/pedagogie/guide-soutenance-realisation.md @@ -0,0 +1,288 @@ +# Guide de correction / soutenance — ce qui a été réalisé + +> **Objectif :** suivre le [sujet Swifty Companion v4](../sujets/swifty-companion.md) point par point, avec **fichiers**, **comportement** et **démo** à ne pas oublier en peer-eval. +> **Code évalué :** dépôt école = dossier [`swiftyCompanion/`](../../swiftyCompanion/) (pas `docs/`). + +--- + +## 1. Vue d’ensemble + +| Élément | Détail | +|---------|--------| +| Framework | **Flutter** (Dart), Material 3 | +| API | **42 Intra v2** — `https://api.intra.42.fr/v2` | +| Auth | **OAuth2** — `flutter_appauth` 12.x | +| Secrets | **`.env`** dans `swiftyCompanion/swifty_companion/.env` (asset, non versionné) | +| Vues | **2** — recherche (`SearchScreen`) + profil (`ProfileScreen`) | +| Bonus | **Refresh token** sur 401 + retry des requêtes API | + +**Point d’entrée :** [`swiftyCompanion/swifty_companion/lib/main.dart`](../../swiftyCompanion/swifty_companion/lib/main.dart) → `home: SearchScreen()`. + +--- + +## 2. Chapitre IV — Instructions générales & sécurité + +| Exigence sujet | Réalisation | Fichiers | +|----------------|-------------|----------| +| Langage mobile (Flutter cité) | Projet Flutter standard | `swifty_companion/pubspec.yaml` | +| Dernière API 42 | Base URL v2 | [`lib/config/intra_config.dart`](../../swiftyCompanion/swifty_companion/lib/config/intra_config.dart) — `apiBaseUrl` | +| Secrets en **`.env`**, **pas dans Git** | `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI` | `.env` (local) · modèle [`swiftyCompanion/.env.example`](../../swiftyCompanion/.env.example) · ignore [`swiftyCompanion/.gitignore`](../../swiftyCompanion/.gitignore) et [`.gitignore` racine travail](../../.gitignore) | +| Chargement `.env` au démarrage | `dotenv.load` | [`lib/main.dart`](../../swiftyCompanion/swifty_companion/lib/main.dart) + asset dans `pubspec.yaml` (`assets: - .env`) | + +**À dire en correction :** les clés OAuth ne sont jamais dans le code source ; l’évaluateur configure son propre `.env` à partir de `.env.example`. + +--- + +## 3. Chapitre V.1 — Partie obligatoire (9 critères) + +### Critère 1 — Au moins 2 vues + +| Vue | Rôle | Fichier | +|-----|------|---------| +| **Vue 1** | Connexion API + recherche par login | [`lib/screens/search_screen.dart`](../../swiftyCompanion/swifty_companion/lib/screens/search_screen.dart) | +| **Vue 2** | Profil étudiant | [`lib/screens/profile_screen.dart`](../../swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart) | + +**Démo :** montrer explicitement les deux écrans après une recherche réussie. + +--- + +### Critère 2 — Gérer tous les cas d’erreur + +| Situation | Message utilisateur (ex.) | Où c’est géré | +|-----------|---------------------------|---------------| +| Pas connecté OAuth | « Connecte-toi d’abord à l’API 42 » | [`intra_api_exception.dart`](../../swiftyCompanion/swifty_companion/lib/services/intra_api_exception.dart) — `IntraNotAuthenticatedException` · [`intra_user_service.dart`](../../swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart) | +| Login vide | « Saisis un login » | `IntraUserService.fetchUserByLogin` | +| Login inconnu | « Aucun utilisateur pour ce login » | `IntraUserNotFoundException` | +| Pas de réseau | « Connexion impossible. Vérifie le réseau. » | `IntraNetworkException` · `SocketException` / `ClientException` dans user + profile services | +| OAuth annulé / échec | Messages OAuth / plateforme | [`search_screen.dart`](../../swiftyCompanion/swifty_companion/lib/screens/search_screen.dart) — `_connect()` | +| Session expirée (401) | « Session expirée. Reconnecte-toi… » | `IntraUnauthorizedException` · [`intra_http.dart`](../../swiftyCompanion/swifty_companion/lib/services/intra_http.dart) | +| Accès refusé (403) | Scopes `public` + `projects` | `IntraForbiddenException` | +| Trop de requêtes (429) | Message + attente / retry | `IntraRateLimitException` · retry dans `intra_http.dart` | +| Erreur profil (réseau, API) | Message + bouton **Réessayer** | `profile_screen.dart` — `_ErrorBody` | +| `.env` manquant | Texte explicite au lancement | `search_screen.dart` — branche `!IntraConfig.isConfigured` | + +**Démo suggérée :** login `zzzzinvalid` → mode avion → (optionnel) profil en erreur + réessai. + +--- + +### Critère 3 — Vue 2 : infos du login s’il existe + +- Navigation **uniquement** si l’API renvoie un utilisateur : [`search_screen.dart`](../../swiftyCompanion/swifty_companion/lib/screens/search_screen.dart) — `_search()` → `Navigator.push(ProfileScreen(user: …))`. +- Si liste vide → exception, **pas** d’ouverture du profil. + +--- + +### Critère 4 — ≥ 4 détails + photo de profil + +| 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` | +| **Email** | `user['email']` | `_DetailsCard` | +| **Téléphone** | `user['phone']` | `_DetailsCard` | +| **Niveau cursus** | `cursus_users` (slug `42`) | `IntraProfileService` + `_DetailsCard` | +| **Localisation** | `user['location']` | `_DetailsCard` | +| **Wallet** | `user['wallet']` | `_DetailsCard` | + +Chargement profil : [`lib/services/intra_profile_service.dart`](../../swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart) — `GET /users/{login}`, puis `projects_users`. + +**Note sujet :** champs masqués par l’API → affichage `—` (confidentialité OAuth). + +**Non affiché (optionnel sujet) :** `evaluation_points` — tu as déjà **> 4** champs sans lui. + +--- + +### Critère 5 — Skills : niveau + pourcentage + +- **Pas** `/experiences` (souvent 403). +- Skills depuis **`cursus_users[].skills`** sur la fiche user. +- Niveau entier = partie entière du `level` API ; **%** = partie décimale × 100. +- Parsing : `IntraProfileService._parseSkillsFromCursus` · affichage : `_SkillsCard` + barre de progression. + +**Démo :** lire une ligne du type `Algorithms — niveau 8 (42 %)`. + +--- + +### Critère 6 — Projets réalisés, y compris failed + +- `GET /users/{id}/projects_users?page[size]=100` +- Filtre app : statuts **`finished`** et **`failed`** — `_parseProjects` dans `intra_profile_service.dart`. +- Liste : `_ProjectsCard` (libellés Validé / Échec + note si présente). + +**Scope OAuth requis :** `projects` (sinon 403). + +--- + +### Critère 7 — Revenir à la vue 1 + +- `MaterialPageRoute` + **retour système** / flèche AppBar sur le profil. +- `ProfileScreen` : `SwiftyUi.appBar(..., showBack: true)`. +- Session OAuth **conservée** sur `SearchScreen` (pas de nouveau login). + +**Démo :** retour → nouvelle recherche sans « Se connecter avec 42 ». + +--- + +### Critère 8 — Layout flexible / moderne + +| Mécanisme | Fichier | +|-----------|---------| +| Scroll + clavier | `ResponsiveScrollBody` — [`lib/theme/swifty_ui.dart`](../../swiftyCompanion/swifty_companion/lib/theme/swifty_ui.dart) | +| Formulaire centré (vue 1) | `searchMaxWidth` 480 px | +| Profil 2 colonnes si large | `wideBreakpoint` 720 px — `profile_screen.dart` `LayoutBuilder` | +| Lignes détail empilées si étroit | `_DetailRow` si largeur < 400 px | +| Thème 42 turquoise | `SwiftyUi.brand` · `main.dart` `ColorScheme.fromSeed` | + +**Démo :** rotation paysage ou grand écran → 2 colonnes sur le profil. + +--- + +### Critère 9 — Un token OAuth, pas un token par requête + +| Règle | Implémentation | +|-------|----------------| +| Un seul login OAuth | `IntraAuthService.instance` — [`intra_auth_service.dart`](../../swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart) | +| Bearer sur chaque GET | `authHeaders()` | +| Pas de OAuth à chaque recherche | `IntraUserService` / `IntraProfileService` réutilisent le même service | + +**À dire :** un flux `authorizeAndExchangeCode` au début ; ensuite uniquement des `GET` API avec le même access token. + +--- + +## 4. OAuth2 Intra (encadrés sujet) + +| Élément | Valeur / choix | Fichiers | +|---------|----------------|----------| +| Authorize | `https://api.intra.42.fr/oauth/authorize` | `intra_config.dart` | +| Token | `https://api.intra.42.fr/oauth/token` | `intra_config.dart` + refresh dans `intra_auth_service.dart` | +| Scopes | `public`, `projects` | `intra_auth_service.dart` — `AuthorizationTokenRequest` | +| Redirect (device) | `com.example.swiftycompanion://callback` | `.env` · [`intra-oauth-formulaire.md`](intra-oauth-formulaire.md) | +| Android redirect | `RedirectUriReceiverActivity` | [`android/app/src/main/AndroidManifest.xml`](../../swiftyCompanion/swifty_companion/android/app/src/main/AndroidManifest.xml) | +| Gradle scheme | `appAuthRedirectScheme` | [`android/app/build.gradle.kts`](../../swiftyCompanion/swifty_companion/android/app/build.gradle.kts) | + +**Doc perso (non rendue Intra) :** [etape-02-oauth-intra.md](etape-02-oauth-intra.md), [oauth-test-manuel.md](oauth-test-manuel.md). + +--- + +## 5. Chapitre V.2 — Bonus (refresh token) + +| Exigence | Réalisation | Fichiers | +|----------|-------------|----------| +| Stocker refresh au login | `result.refreshToken` | `intra_auth_service.dart` — `signIn()` | +| Rafraîchir à expiration | `POST` `grant_type=refresh_token` | `refreshAccessToken()` | +| App continue | Sur **401** → refresh → **rejouer** le GET | `intra_http.dart` — `intraApiGet(..., auth:)` | +| Parsing JSON token | | `intra_oauth_tokens.dart` + tests | +| Test manuel debug | Bouton « DEBUG : simuler token expiré » | `search_screen.dart` (`kDebugMode` uniquement) puis **Rechercher** | + +**Limite assumée :** tokens en **mémoire** seulement — après kill de l’app, reconnexion OAuth (acceptable si tu l’expliques). + +**Guide détaillé :** [etape-07-bonus-token.md](etape-07-bonus-token.md). + +--- + +## 6. Inventaire des fichiers applicatifs (lib/) + +Fichiers **métier** à connaître pour la soutenance : + +``` +swiftyCompanion/swifty_companion/lib/ +├── main.dart # App + thème + dotenv +├── config/ +│ └── intra_config.dart # URLs + variables .env +├── services/ +│ ├── intra_auth_service.dart # OAuth, access + refresh +│ ├── intra_user_service.dart # Recherche login +│ ├── intra_profile_service.dart # Profil agrégé +│ ├── intra_http.dart # GET + 401/429 +│ ├── intra_oauth_tokens.dart # Parse token JSON (bonus) +│ └── intra_api_exception.dart # Erreurs métier +├── screens/ +│ ├── search_screen.dart # Vue 1 +│ └── profile_screen.dart # Vue 2 +└── theme/ + └── swifty_ui.dart # Layout partagé (étape 5) +``` + +**Tests :** + +``` +test/ +├── widget_test.dart # Démarre app + bouton connexion +└── intra_oauth_tokens_test.dart # Parse refresh (bonus) +``` + +**Fichier supprimé (nettoyage) :** `auth_screen.dart` (écran test obsolète, non utilisé). + +**Plateforme Android (OAuth physique) :** `AndroidManifest.xml`, `build.gradle.kts` (voir §4). + +--- + +## 7. Endpoints API utilisés + +| Usage | Méthode | Appelé depuis | +|-------|---------|---------------| +| Session courante | `GET /v2/me` | `IntraAuthService.fetchMe` (après login) | +| Recherche | `GET /v2/users?filter[login]=` | `IntraUserService` | +| Fiche complète | `GET /v2/users/{login}` | `IntraProfileService` | +| Cursus (secours) | `GET /v2/users/{id}/cursus_users` | `IntraProfileService` si pas embarqué | +| Projets | `GET /v2/users/{id}/projects_users` | `IntraProfileService` | +| Refresh token | `POST /oauth/token` | `IntraAuthService.refreshAccessToken` | + +Requêtes **séquentielles** + cache profil 2 min + retry 429 : voir [etape-04-vue-profil.md](etape-04-vue-profil.md). + +--- + +## 8. Chapitre VI — Rendu Git + +| À pousser sur Vogosphere | À ne **pas** pousser | +|--------------------------|----------------------| +| Contenu de `swiftyCompanion/` | Dossier `docs/` (dépôt perso) | +| `README.md`, `.gitignore`, `.env.example` | Fichier `.env` | +| `swifty_companion/` (code Flutter) | `.env copy`, secrets dans l’historique Git | + +Checklist : [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) · [swiftyCompanion/README.md](../../swiftyCompanion/README.md). + +--- + +## 9. Script de démo (5–8 min) + +1. `cd swiftyCompanion/swifty_companion` → `flutter test` (rapide). +2. `flutter run -d ` — montrer `.env` configuré **sans** afficher les secrets. +3. **Se connecter avec 42** — une fois. +4. Recherche login valide → **profil** : photo, champs, skills, projet failed si dispo. +5. Retour → **deuxième** recherche (même session). +6. Login invalide → message. +7. Mode avion → erreur réseau. +8. **Bonus :** DEBUG simuler token expiré → **Rechercher** → OK sans OAuth navigateur. +9. Rappeler : 2 vues, 1 token, `.env` ignoré par git. + +--- + +## 10. Parcours pédagogique (docs perso uniquement) + +| Étape | Guide | +|-------|--------| +| 0–1 | [00-reprise-et-etat.md](00-reprise-et-etat.md) | +| 2 OAuth | [etape-02-oauth-intra.md](etape-02-oauth-intra.md) | +| 3 Recherche | [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | +| 4 Profil | [etape-04-vue-profil.md](etape-04-vue-profil.md) | +| 5 Layout | [etape-05-navigation-layout.md](etape-05-navigation-layout.md) | +| 6 Soutenance | [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) | +| 7 Bonus | [etape-07-bonus-token.md](etape-07-bonus-token.md) | +| **Ce document** | **Cartographie sujet ↔ code pour la correction** | + +--- + +## 11. Checklist « je n’ai rien oublié » (avant peer-eval) + +- [ ] App OAuth Intra : scopes **public** + **projects**, redirect = `.env` +- [ ] `.env` présent sur la machine de démo, **absent** du Git école +- [ ] `flutter analyze` / `flutter test` OK +- [ ] Parcours nominal + erreurs + retour testés sur **device** +- [ ] Bonus refresh testé (DEBUG → Rechercher) +- [ ] Prêt à expliquer **où** est chaque critère du sujet (ce guide §3) +- [ ] SECRET régénéré si jamais exposé (chat, commit accidentel) + +--- + +*Dernière alignement : sujet PDF v4 — traduction [swifty-companion.md](../sujets/swifty-companion.md).* diff --git a/docs/sujets/swifty-companion.md b/docs/sujets/swifty-companion.md index 3b5addc..afe44fc 100644 --- a/docs/sujets/swifty-companion.md +++ b/docs/sujets/swifty-companion.md @@ -145,6 +145,8 @@ Déposez le travail dans votre **dépôt Git** comme d’habitude. **Seul le con → Checklist soutenance (dépôt école) : [../../swiftyCompanion/README.md](../../swiftyCompanion/README.md) — dossier **non** inclus dans le rendu Intra si tu ne pousses que ce sous-répertoire. +→ Guide détaillé **sujet ↔ code** (dépôt perso) : [../pedagogie/guide-soutenance-realisation.md](../pedagogie/guide-soutenance-realisation.md) + --- ## Éléments visuels du PDF diff --git a/swiftyCompanion/README.md b/swiftyCompanion/README.md index 46f93c3..323848a 100644 --- a/swiftyCompanion/README.md +++ b/swiftyCompanion/README.md @@ -2,37 +2,45 @@ Application mobile — profil étudiant 42 via l’**API Intra** (OAuth2). -> **Dépôt de rendu école** : ce dossier est conçu pour être la **racine** du dépôt Git déposé sur l’Intra. Il ne contient pas la documentation de travail (sujet traduit, guides) du dépôt perso. +> **Dépôt de rendu école** : ce dossier est la **racine** du dépôt Git Vogosphere. Pas de `docs/` ni de sujet traduit ici (dépôt perso séparé). -**État :** OAuth + vue recherche + vue profil (skills, projets). Prochaine étape : polish layout / tests device (dépôt de travail : `docs/pedagogie/00-reprise-et-etat.md`). +**État :** partie obligatoire + **bonus refresh token** (401 → refresh → retry API). ## Lancer le projet ```bash cd swifty_companion -# Seul ce fichier compte pour Flutter (asset .env) : -# swifty_companion/.env -# Optionnel : copie de sauvegarde dans swiftyCompanion/.env — garder les deux identiques. +# Créer .env ici (voir .env.example à la racine du dépôt école) flutter pub get flutter run -d ``` -Écrans : **connexion OAuth 42** → **recherche par login** → **profil** (photo, champs, skills, projets validés / failed). +Le fichier **`.env`** doit être dans **`swifty_companion/.env`** (déclaré comme asset dans `pubspec.yaml`). Vérifications : ```bash flutter doctor flutter devices +flutter analyze +flutter test ``` ## Configuration (`.env`) -1. Créer une app OAuth : [profile.intra.42.fr/oauth/applications](https://profile.intra.42.fr/oauth/applications) -2. Copier `.env.example` → `.env` et renseigner `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI` -3. Ne **jamais** pousser `.env` sur le dépôt école (déjà ignoré par `.gitignore`) +1. App OAuth : [profile.intra.42.fr/oauth/applications](https://profile.intra.42.fr/oauth/applications) +2. Scopes : **`public`** + **`projects`** (projets sur le profil) +3. Redirect URI (ex. téléphone) : `com.example.swiftycompanion://callback` — identique sur l’Intra et dans `.env` +4. Ne **jamais** committer `.env` -Documentation API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc) +API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc) + +## Parcours app + +1. **Se connecter avec 42** (token réutilisé pour toutes les requêtes) +2. **Rechercher** un login +3. **Profil** : photo, champs, skills (niveau + %), projets validés et failed +4. **Retour** → nouvelle recherche ## Partie obligatoire (rappel sujet v4) @@ -41,28 +49,24 @@ Documentation API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc) | 1 | Au moins **2 vues** | | 2 | **Toutes** les erreurs gérées (login inconnu, réseau, …) | | 3 | Vue 2 : infos du login s’il **existe** | -| 4 | **≥ 4 détails** (login, email, mobile, level, location, wallet, evaluations, …) + **photo de profil** | +| 4 | **≥ 4 détails** + **photo de profil** | | 5 | **Skills** : niveau + pourcentage | | 6 | **Projets** réalisés, **y compris les failed** | | 7 | Retour possible vers la **vue 1** | -| 8 | Layout **adaptatif** (tailles / plateformes) | -| 9 | **OAuth2 Intra** — **un token réutilisé**, pas un token par requête | +| 8 | Layout **adaptatif** | +| 9 | **OAuth2** — **un token** réutilisé | -Contraintes : dernière API 42 disponible ; secrets uniquement en local (`.env`). - -Certaines données peuvent ne pas être affichées (confidentialité / réglementation). +Certaines données peuvent être masquées par l’API (confidentialité). ## Bonus -- Rafraîchissement du token à expiration ; l’application reste fonctionnelle. - -*(Bonus évalué seulement si la partie obligatoire est parfaite.)* +- Refresh token : `IntraAuthService.refreshAccessToken()` + retry dans `intraApiGet` sur 401. ## Soutenance — checklist peer-eval | # | Test | Démo | |---|------|------| -| 1 | `flutter test` | Tests OK (si présents) | +| 1 | `flutter test` | Tests OK | | 2 | `flutter run -d ` | Vue 1 → login valide → vue 2 | | 3 | Profil | Photo + ≥ 4 champs + skills + projets (dont failed) | | 4 | Login invalide | Message clair, pas de crash | @@ -71,13 +75,13 @@ Certaines données peuvent ne pas être affichées (confidentialité / réglemen | 7 | Petit écran / rotation | UI lisible | | 8 | Bonus | Refresh token si implémenté | -**Ordre suggéré :** login valide → skills / projets → login faux → hors ligne → retour recherche. +**Ordre suggéré :** connexion → profil → login faux → hors ligne → retour → `flutter test`. ## Fichiers de ce dépôt | Fichier | Rôle | |---------|------| -| `README.md` | Ce document (correcteurs / peer-eval) | -| `.gitignore` | Règles Git pour le rendu école | -| `.env.example` | Modèle de variables (sans secrets) | -| `lib/`, `android/`, … | Code Flutter (après `flutter create` ou développement) | +| `README.md` | Ce document | +| `.gitignore` | Exclut `.env`, build, etc. | +| `.env.example` | Modèle sans secrets | +| `swifty_companion/` | Projet Flutter (`lib/`, `android/`, …) | diff --git a/swiftyCompanion/swifty_companion/lib/main.dart b/swiftyCompanion/swifty_companion/lib/main.dart index 2276ebd..5a813cb 100644 --- a/swiftyCompanion/swifty_companion/lib/main.dart +++ b/swiftyCompanion/swifty_companion/lib/main.dart @@ -17,8 +17,15 @@ class SwiftyCompanionApp extends StatelessWidget { return MaterialApp( title: 'Swifty Companion', theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF00BABC)), + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF00BABC), + brightness: Brightness.light, + ), useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + ), ), home: const SearchScreen(), onUnknownRoute: (_) => MaterialPageRoute( diff --git a/swiftyCompanion/swifty_companion/lib/screens/auth_screen.dart b/swiftyCompanion/swifty_companion/lib/screens/auth_screen.dart deleted file mode 100644 index 2b70647..0000000 --- a/swiftyCompanion/swifty_companion/lib/screens/auth_screen.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_appauth/flutter_appauth.dart'; - -import '../config/intra_config.dart'; -import '../services/intra_auth_service.dart'; - -/// Écran temporaire étape 2 : valider OAuth + token unique (/v2/me). -class AuthScreen extends StatefulWidget { - const AuthScreen({super.key}); - - @override - State createState() => _AuthScreenState(); -} - -class _AuthScreenState extends State { - final _auth = IntraAuthService.instance; - - bool _loading = false; - String? _error; - String? _login; - - Future _connect() async { - setState(() { - _loading = true; - _error = null; - _login = null; - }); - - try { - await _auth.signIn(); - final me = await _auth.fetchMe(); - if (!mounted) return; - setState(() { - _login = me['login'] as String?; - _error = null; - }); - } on FlutterAppAuthUserCancelledException { - if (!mounted) return; - setState(() => _error = 'Connexion annulée'); - } on FlutterAppAuthPlatformException catch (e) { - final d = e.platformErrorDetails; - if (!mounted) return; - setState(() { - _error = d.errorDescription ?? - d.error ?? - d.errorDebugDescription ?? - e.message ?? - 'Échec OAuth (UID, SECRET, redirect Intra)'; - }); - } on PlatformException catch (e) { - if (!mounted) return; - setState(() => _error = e.message ?? 'Erreur OAuth'); - } catch (e) { - if (!mounted) return; - setState(() => _error = e.toString()); - } finally { - if (mounted) { - setState(() => _loading = false); - } - } - } - - void _disconnect() { - _auth.signOut(); - setState(() => _login = null); - } - - @override - Widget build(BuildContext context) { - if (!IntraConfig.isConfigured) { - return Scaffold( - appBar: AppBar(title: const Text('Swifty Companion')), - body: const Padding( - padding: EdgeInsets.all(24), - child: Text( - 'Fichier .env manquant ou incomplet.\n' - 'Copie swiftyCompanion/.env vers swifty_companion/.env ' - '(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI).', - ), - ), - ); - } - - return Scaffold( - appBar: AppBar( - title: const Text('Swifty Companion'), - backgroundColor: const Color(0xFF00BABC), - foregroundColor: Colors.black, - ), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Étape 2 — Connexion API 42', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - _auth.isAuthenticated - ? 'Token actif (réutilisé pour /v2/me)' - : 'Connecte-toi une fois ; le token servira pour toutes les requêtes.', - ), - const SizedBox(height: 24), - if (_login != null) ...[ - Text('Connecté : $_login', style: const TextStyle(fontSize: 16)), - const SizedBox(height: 16), - OutlinedButton(onPressed: _disconnect, child: const Text('Déconnexion')), - ] else - FilledButton( - onPressed: _loading ? null : _connect, - child: _loading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Se connecter avec 42'), - ), - if (_error != null) ...[ - const SizedBox(height: 16), - Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ], - const Spacer(), - Text( - 'Config (.env dans swifty_companion/)\n' - 'Redirect : ${IntraConfig.redirectUri}\n' - 'CLIENT_ID …${IntraConfig.clientIdSuffix}\n' - 'Après modification du .env : arrêter l’app puis flutter run (pas hot reload).', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ); - } -} diff --git a/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart b/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart index 65efb93..e550dc2 100644 --- a/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart +++ b/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../services/intra_api_exception.dart'; import '../services/intra_profile_service.dart'; +import '../theme/swifty_ui.dart'; /// Vue 2 — profil complet (étape 4). class ProfileScreen extends StatefulWidget { @@ -79,18 +80,18 @@ class _ProfileScreenState extends State { final imageUrl = _imageUrl(user); return Scaffold( - appBar: AppBar( - title: Text(login), - backgroundColor: const Color(0xFF00BABC), - foregroundColor: Colors.black, - ), + appBar: SwiftyUi.appBar(title: login, showBack: true), body: _loading ? const Center(child: CircularProgressIndicator()) : _error != null - ? _ErrorBody(message: _error!, onRetry: _load) + ? ResponsiveScrollBody( + maxWidth: SwiftyUi.searchMaxWidth, + child: _ErrorBody(message: _error!, onRetry: _load), + ) : LayoutBuilder( builder: (context, constraints) { - final wide = constraints.maxWidth >= 720; + final wide = + constraints.maxWidth >= SwiftyUi.wideBreakpoint; final profile = _profile!; final header = _ProfileHeader( login: login, @@ -104,51 +105,65 @@ class _ProfileScreenState extends State { final skills = _SkillsCard(skills: profile.skills); final projects = _ProjectsCard(projects: profile.projects); + final column = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + const SizedBox(height: 16), + details, + const SizedBox(height: 16), + skills, + const SizedBox(height: 16), + projects, + ], + ); + if (wide) { return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 2, - child: Column( - children: [ - header, - const SizedBox(height: 16), - details, - ], - ), + padding: SwiftyUi.pagePadding(constraints), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: SwiftyUi.contentMaxWidth, ), - const SizedBox(width: 24), - Expanded( - flex: 3, - child: Column( - children: [ - skills, - const SizedBox(height: 16), - projects, - ], - ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + children: [header, const SizedBox(height: 16), details], + ), + ), + const SizedBox(width: 24), + Expanded( + flex: 3, + child: Column( + children: [ + skills, + const SizedBox(height: 16), + projects, + ], + ), + ), + ], ), - ], + ), ), ); } return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - header, - const SizedBox(height: 16), - details, - const SizedBox(height: 16), - skills, - const SizedBox(height: 16), - projects, - ], + padding: SwiftyUi.pagePadding(constraints), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: SwiftyUi.contentMaxWidth, + ), + child: column, + ), ), ); }, @@ -165,20 +180,18 @@ class _ErrorBody extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - message, - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 16), - FilledButton(onPressed: onRetry, child: const Text('Réessayer')), - ], - ), + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 48), + ErrorMessage(message: message), + const SizedBox(height: 20), + LoadingFilledButton( + label: 'Réessayer', + loading: false, + onPressed: onRetry, + ), + ], ); } } @@ -269,21 +282,39 @@ class _DetailRow extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - label, - style: const TextStyle(fontWeight: FontWeight.w600), + return LayoutBuilder( + builder: (context, constraints) { + final stacked = constraints.maxWidth < 400; + if (stacked) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(value), + ], ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Expanded(child: Text(value)), + ], ), - Expanded(child: Text(value)), - ], - ), + ); + }, ); } } @@ -318,7 +349,7 @@ class _SkillsCard extends StatelessWidget { value: (s.level + s.percentage / 100).clamp(0, 15) / 15, minHeight: 6, backgroundColor: Colors.grey.shade300, - color: const Color(0xFF00BABC), + color: SwiftyUi.brand, ), ], ), @@ -356,18 +387,25 @@ class _ProjectsCard extends StatelessWidget { final statusLabel = p.status == 'failed' ? 'Échec' : 'Validé'; final mark = p.finalMark != null ? ' · ${p.finalMark}' : ''; - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: Text(p.name), - trailing: Text( - '$statusLabel$mark', - style: TextStyle( - color: p.status == 'failed' - ? Theme.of(context).colorScheme.error - : Colors.green.shade700, - fontWeight: FontWeight.w600, - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text(p.name), + ), + const SizedBox(width: 8), + Text( + '$statusLabel$mark', + style: TextStyle( + color: p.status == 'failed' + ? Theme.of(context).colorScheme.error + : Colors.green.shade700, + fontWeight: FontWeight.w600, + ), + ), + ], ), ); }), diff --git a/swiftyCompanion/swifty_companion/lib/screens/search_screen.dart b/swiftyCompanion/swifty_companion/lib/screens/search_screen.dart index e1271bf..a84d5fc 100644 --- a/swiftyCompanion/swifty_companion/lib/screens/search_screen.dart +++ b/swiftyCompanion/swifty_companion/lib/screens/search_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; @@ -6,6 +7,7 @@ import '../config/intra_config.dart'; import '../services/intra_api_exception.dart'; import '../services/intra_auth_service.dart'; import '../services/intra_user_service.dart'; +import '../theme/swifty_ui.dart'; import 'profile_screen.dart'; /// Vue 1 — recherche par login 42. @@ -106,9 +108,9 @@ class _SearchScreenState extends State { Widget build(BuildContext context) { if (!IntraConfig.isConfigured) { return Scaffold( - appBar: AppBar(title: const Text('Swifty Companion')), - body: const Padding( - padding: EdgeInsets.all(24), + appBar: SwiftyUi.appBar(title: 'Swifty Companion'), + body: const ResponsiveScrollBody( + maxWidth: SwiftyUi.searchMaxWidth, child: Text( 'Fichier .env manquant dans swifty_companion/.env', ), @@ -117,55 +119,50 @@ class _SearchScreenState extends State { } final isAuthenticated = _auth.isAuthenticated; + final busy = _connecting || _searching; return Scaffold( - appBar: AppBar( - title: const Text('Swifty Companion'), - backgroundColor: const Color(0xFF00BABC), - foregroundColor: Colors.black, + appBar: SwiftyUi.appBar( + title: 'Swifty Companion', actions: [ if (isAuthenticated) IconButton( - onPressed: _connecting || _searching ? null : _disconnect, + onPressed: busy ? null : _disconnect, icon: const Icon(Icons.logout), tooltip: 'Déconnexion API', ), ], ), - body: Padding( - padding: const EdgeInsets.all(24), + body: ResponsiveScrollBody( + maxWidth: SwiftyUi.searchMaxWidth, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!isAuthenticated) ...[ - const Text( + Text( 'Connexion API 42', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), - const Text( + Text( 'Connecte-toi une fois ; le token servira pour toutes les recherches.', + style: Theme.of(context).textTheme.bodyMedium, ), - const SizedBox(height: 16), - FilledButton( - onPressed: _connecting ? null : _connect, - child: _connecting - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Se connecter avec 42'), + const SizedBox(height: 20), + LoadingFilledButton( + label: 'Se connecter avec 42', + loading: _connecting, + onPressed: _connect, ), ] else ...[ Text( 'Session : ${_sessionLogin ?? 'connecté'}', style: Theme.of(context).textTheme.titleMedium, ), - const SizedBox(height: 24), - const Text( + const SizedBox(height: 28), + Text( 'Rechercher un étudiant', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), TextField( @@ -176,29 +173,40 @@ class _SearchScreenState extends State { border: OutlineInputBorder(), ), textInputAction: TextInputAction.search, + autocorrect: false, + enableSuggestions: false, onSubmitted: (_) { if (!_searching) _search(); }, enabled: !_searching, ), const SizedBox(height: 16), - FilledButton( - onPressed: _searching ? null : _search, - child: _searching - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Rechercher'), + LoadingFilledButton( + label: 'Rechercher', + loading: _searching, + onPressed: _search, ), + if (kDebugMode) ...[ + const SizedBox(height: 12), + TextButton( + onPressed: _auth.canRefresh + ? () { + _auth.debugInvalidateAccessToken(); + setState(() { + _error = + 'DEBUG : access token invalidé (refresh conservé). ' + 'Étape suivante : saisis un login et appuie sur « Rechercher ». ' + 'Si le bonus marche, le profil s’ouvre sans repasser par OAuth 42.'; + }); + } + : null, + child: const Text('DEBUG : simuler token expiré'), + ), + ], ], if (_error != null) ...[ - const SizedBox(height: 16), - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), + const SizedBox(height: 20), + ErrorMessage(message: _error!), ], ], ), diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart b/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart index aaa7a4f..3191618 100644 --- a/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart +++ b/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart @@ -1,11 +1,14 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:http/http.dart' as http; import '../config/intra_config.dart'; +import 'intra_http.dart'; +import 'intra_oauth_tokens.dart'; -/// OAuth2 Intra : un token réutilisé pour toutes les requêtes API. +/// OAuth2 Intra : access + refresh token, un jeton réutilisé pour les requêtes API. class IntraAuthService { IntraAuthService._(); static final IntraAuthService instance = IntraAuthService._(); @@ -13,9 +16,13 @@ class IntraAuthService { final FlutterAppAuth _appAuth = const FlutterAppAuth(); String? _accessToken; + String? _refreshToken; + Future? _refreshInFlight; String? get accessToken => _accessToken; bool get isAuthenticated => _accessToken != null && _accessToken!.isNotEmpty; + bool get canRefresh => + _refreshToken != null && _refreshToken!.isNotEmpty; static final AuthorizationServiceConfiguration _serviceConfiguration = AuthorizationServiceConfiguration( @@ -36,20 +43,75 @@ class IntraAuthService { IntraConfig.redirectUri, clientSecret: IntraConfig.clientSecret, serviceConfiguration: _serviceConfiguration, - // `projects` requis pour projects_users ; skills via cursus_users (pas /experiences). scopes: ['public', 'projects'], ), ); - _accessToken = result.accessToken; + _applyOAuthResult(result.accessToken, result.refreshToken); if (!isAuthenticated) { throw StateError('Connexion 42 annulée ou token absent'); } } + void _applyOAuthResult(String? access, String? refresh) { + _accessToken = access; + if (refresh != null && refresh.isNotEmpty) { + _refreshToken = refresh; + } + } + void signOut() { _accessToken = null; + _refreshToken = null; + _refreshInFlight = null; + } + + /// Rafraîchit l’access token (bonus). Retourne `false` si impossible. + Future refreshAccessToken() async { + if (!canRefresh) return false; + + final inFlight = _refreshInFlight; + if (inFlight != null) return inFlight; + + final future = _refreshAccessTokenOnce(); + _refreshInFlight = future; + try { + return await future; + } finally { + if (identical(_refreshInFlight, future)) { + _refreshInFlight = null; + } + } + } + + Future _refreshAccessTokenOnce() async { + if (!canRefresh) return false; + + final response = await http.post( + Uri.parse(IntraConfig.tokenUrl), + headers: const {'Accept': 'application/json'}, + body: { + 'grant_type': 'refresh_token', + 'client_id': IntraConfig.clientId, + 'client_secret': IntraConfig.clientSecret, + 'refresh_token': _refreshToken!, + }, + ); + + if (response.statusCode != 200) return false; + + final decoded = jsonDecode(response.body); + if (decoded is! Map) return false; + + final pair = parseOAuthTokenResponse(decoded); + if (pair == null) return false; + + _accessToken = pair.accessToken; + if (pair.refreshToken != null) { + _refreshToken = pair.refreshToken; + } + return true; } /// Header Authorization pour les appels `api.intra.42.fr`. @@ -64,15 +126,23 @@ class IntraAuthService { } Future> fetchMe() async { - final response = await http.get( + final response = await intraApiGet( Uri.parse('${IntraConfig.apiBaseUrl}/me'), - headers: authHeaders(), + auth: this, ); - - if (response.statusCode != 200) { - throw Exception('GET /v2/me a échoué (${response.statusCode})'); - } - return jsonDecode(response.body) as Map; } + + @visibleForTesting + void debugSetTokens({String? access, String? refresh}) { + _accessToken = access; + _refreshToken = refresh; + } + + /// Simule un access token expiré (garde le refresh) — tests bonus en debug. + @visibleForTesting + void debugInvalidateAccessToken() { + if (!canRefresh) return; + _accessToken = 'invalid_access_token_for_refresh_test'; + } } diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_http.dart b/swiftyCompanion/swifty_companion/lib/services/intra_http.dart index 7813e84..7c94a75 100644 --- a/swiftyCompanion/swifty_companion/lib/services/intra_http.dart +++ b/swiftyCompanion/swifty_companion/lib/services/intra_http.dart @@ -3,15 +3,26 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'intra_api_exception.dart'; +import 'intra_auth_service.dart'; -/// GET API 42 avec gestion 401 / 429 (retry + backoff). +/// GET API 42 avec gestion 401 (refresh token), 429 (retry + backoff). Future intraApiGet( Uri uri, { - required Map headers, + required IntraAuthService auth, int maxAttempts = 4, }) async { + var retriedAfterRefresh = false; + for (var attempt = 0; attempt < maxAttempts; attempt++) { - final response = await http.get(uri, headers: headers); + final response = await http.get(uri, headers: auth.authHeaders()); + + if (response.statusCode == 401) { + if (!retriedAfterRefresh && await auth.refreshAccessToken()) { + retriedAfterRefresh = true; + continue; + } + throw const IntraUnauthorizedException(); + } if (response.statusCode == 429) { if (attempt + 1 >= maxAttempts) { @@ -35,7 +46,6 @@ Duration _retryDelay(http.Response response, int attempt) { return Duration(seconds: seconds.clamp(1, 30)); } } - // 0.8s, 1.6s, 3.2s… final ms = 800 * (1 << attempt); return Duration(milliseconds: ms.clamp(800, 8000)); } diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_oauth_tokens.dart b/swiftyCompanion/swifty_companion/lib/services/intra_oauth_tokens.dart new file mode 100644 index 0000000..cb9f306 --- /dev/null +++ b/swiftyCompanion/swifty_companion/lib/services/intra_oauth_tokens.dart @@ -0,0 +1,21 @@ +/// Résultat d’un `POST /oauth/token` (connexion ou refresh). +class OAuthTokenPair { + const OAuthTokenPair({ + required this.accessToken, + this.refreshToken, + }); + + final String accessToken; + final String? refreshToken; +} + +OAuthTokenPair? parseOAuthTokenResponse(Map json) { + final access = json['access_token']; + if (access is! String || access.isEmpty) return null; + + final refresh = json['refresh_token']; + return OAuthTokenPair( + accessToken: access, + refreshToken: refresh is String && refresh.isNotEmpty ? refresh : null, + ); +} diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart b/swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart index 24150de..7a1d99b 100644 --- a/swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart +++ b/swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart @@ -132,10 +132,7 @@ class IntraProfileService { final uri = Uri.parse('${IntraConfig.apiBaseUrl}$path').replace( queryParameters: query, ); - final response = await intraApiGet( - uri, - headers: _auth.authHeaders(), - ); + final response = await intraApiGet(uri, auth: _auth); return decodeIntraBody(response); } diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart b/swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart index db51aa4..f9ac44d 100644 --- a/swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart +++ b/swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart @@ -29,10 +29,7 @@ class IntraUserService { ); try { - final response = await intraApiGet( - uri, - headers: _auth.authHeaders(), - ); + final response = await intraApiGet(uri, auth: _auth); return _parseUserListResponse(response, trimmed); } on IntraApiException { rethrow; diff --git a/swiftyCompanion/swifty_companion/lib/theme/swifty_ui.dart b/swiftyCompanion/swifty_companion/lib/theme/swifty_ui.dart new file mode 100644 index 0000000..e6bf51f --- /dev/null +++ b/swiftyCompanion/swifty_companion/lib/theme/swifty_ui.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +/// Constantes UI partagées (étape 5 — layout). +abstract final class SwiftyUi { + static const Color brand = Color(0xFF00BABC); + + /// Largeur max du contenu sur tablette / paysage. + static const double contentMaxWidth = 960; + + /// Largeur max vue recherche (formulaire centré). + static const double searchMaxWidth = 480; + + static const double wideBreakpoint = 720; + + static EdgeInsets pagePadding(BoxConstraints constraints) { + final horizontal = constraints.maxWidth >= 600 ? 32.0 : 16.0; + return EdgeInsets.fromLTRB(horizontal, 24, horizontal, 24); + } + + static PreferredSizeWidget appBar({ + required String title, + List? actions, + bool showBack = false, + }) { + return AppBar( + title: Text(title), + backgroundColor: brand, + foregroundColor: Colors.black, + automaticallyImplyLeading: showBack, + actions: actions, + ); + } +} + +/// Corps scrollable centré avec largeur max (clavier + petits écrans). +class ResponsiveScrollBody extends StatelessWidget { + const ResponsiveScrollBody({ + super.key, + required this.child, + this.maxWidth = SwiftyUi.contentMaxWidth, + this.center = true, + }); + + final Widget child; + final double maxWidth; + final bool center; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final content = ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: child, + ); + + return SingleChildScrollView( + padding: SwiftyUi.pagePadding(constraints), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: center + ? Align(alignment: Alignment.topCenter, child: content) + : content, + ); + }, + ); + } +} + +class ErrorMessage extends StatelessWidget { + const ErrorMessage({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return SelectableText( + message, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ); + } +} + +class LoadingFilledButton extends StatelessWidget { + const LoadingFilledButton({ + super.key, + required this.label, + required this.loading, + required this.onPressed, + }); + + final String label; + final bool loading; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return FilledButton( + onPressed: loading ? null : onPressed, + child: loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(label), + ); + } +} diff --git a/swiftyCompanion/swifty_companion/test/intra_oauth_tokens_test.dart b/swiftyCompanion/swifty_companion/test/intra_oauth_tokens_test.dart new file mode 100644 index 0000000..d626ba4 --- /dev/null +++ b/swiftyCompanion/swifty_companion/test/intra_oauth_tokens_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:swifty_companion/services/intra_oauth_tokens.dart'; + +void main() { + group('parseOAuthTokenResponse', () { + test('extrait access et refresh', () { + final pair = parseOAuthTokenResponse({ + 'access_token': 'new_access', + 'refresh_token': 'new_refresh', + 'token_type': 'bearer', + }); + + expect(pair, isNotNull); + expect(pair!.accessToken, 'new_access'); + expect(pair.refreshToken, 'new_refresh'); + }); + + test('accepte access seul', () { + final pair = parseOAuthTokenResponse({ + 'access_token': 'only_access', + }); + + expect(pair?.accessToken, 'only_access'); + expect(pair?.refreshToken, isNull); + }); + + test('rejette access vide ou absent', () { + expect(parseOAuthTokenResponse({}), isNull); + expect(parseOAuthTokenResponse({'access_token': ''}), isNull); + }); + }); +}