guidesoutenance

This commit is contained in:
2026-09-30 08:24:20 +02:00
parent 4de305bbf4
commit 07f88198c0
21 changed files with 1067 additions and 331 deletions

View File

@@ -44,9 +44,9 @@ Tout ce qui concerne correcteurs et soutenance : **[swiftyCompanion/README.md](s
## Reprise du projet ## 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 ## Démarrage

View File

@@ -1,6 +1,6 @@
# Reprise du projet — où nous en sommes # 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 ## En bref
@@ -9,7 +9,7 @@
| **Fait** | Structure dépôt perso, sujet FR, paquet rendu, **projet Flutter** (`swifty_companion/`) | | **Fait** | Structure dépôt perso, sujet FR, paquet rendu, **projet Flutter** (`swifty_companion/`) |
| **Fait** | OAuth + token + `/v2/me` (login connecté) | | **Fait** | OAuth + token + `/v2/me` (login connecté) |
| **Fait** | Vue 1 recherche + vue 2 profil (skills, projets, ≥ 4 champs) | | **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 ## Arborescence actuelle
@@ -49,21 +49,23 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso)
| **2** | OAuth2 (`IntraAuthService`, `/v2/me`) | ✅ **Validé** | | **2** | OAuth2 (`IntraAuthService`, `/v2/me`) | ✅ **Validé** |
| **3** | Vue 1 : `SearchScreen` + `IntraUserService` + navigation profil | ✅ **Codé** — tester sur téléphone | | **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 | | **4** | Vue 2 : `IntraProfileService` + profil (skills, projets, champs) | ✅ **Codé** — tester sur téléphone |
| **5** | Retour vue 1 + layout adaptatif (polish) | ⏳ **Prochaine** | | **5** | Retour vue 1 + layout adaptatif (`swifty_ui.dart`) | ✅ **Codé** — [etape-05-navigation-layout.md](etape-05-navigation-layout.md) |
| **6** | Passage checklist [swiftyCompanion/README.md](../../swiftyCompanion/README.md) | ⏳ | | **6** | Checklist soutenance + rendu école | ✅ **Guide** — [etape-06-checklist-soutenance.md](etape-06-checklist-soutenance.md) |
| **7** | Bonus : refresh token à expiration | ⏳ | | **7** | Bonus : refresh token à expiration | ✅ **Codé** — [etape-07-bonus-token.md](etape-07-bonus-token.md) |
## Code applicatif (`swifty_companion/lib/`) ## Code applicatif (`swifty_companion/lib/`)
| Fichier | Rôle | | Fichier | Rôle |
|---------|------| |---------|------|
| `config/intra_config.dart` | `.env`, URLs OAuth / API | | `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_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 | | `services/intra_api_exception.dart` | Erreurs métier |
| `screens/search_screen.dart` | **Vue 1** | | `screens/search_screen.dart` | **Vue 1** |
| `screens/profile_screen.dart` | **Vue 2** | | `screens/profile_screen.dart` | **Vue 2** |
| `theme/swifty_ui.dart` | Layout responsive partagé |
| `main.dart` | `home: SearchScreen()` | | `main.dart` | `home: SearchScreen()` |
## Reprendre le travail (checklist) ## Reprendre le travail (checklist)
@@ -82,6 +84,7 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso)
| Tu veux… | Fichier | | 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) | | Comprendre le sujet | [docs/sujets/swifty-companion.md](../sujets/swifty-companion.md) |
| Plan complet des étapes | [docs/pedagogie/README.md](README.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) | | 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-02-oauth-intra.md](etape-02-oauth-intra.md) | ✅ |
| [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | ✅ | | [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | ✅ |
| [etape-04-vue-profil.md](etape-04-vue-profil.md) | ✅ | | [etape-04-vue-profil.md](etape-04-vue-profil.md) | ✅ |
| `etape-05-navigation-layout.md` | 🔜 | | [etape-05-navigation-layout.md](etape-05-navigation-layout.md) | ✅ |
| `etape-06-bonus-token.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 ## Notes pour la session suivante
- Valider étapes 3–4 sur **device réel** (recherche, profil, mode avion sur vue 2). - Partie obligatoire : prête pour peer-eval si la checklist étape 6 est OK sur device.
- Étape 5 : harmoniser marges / contraintes sur les deux écrans si besoin en soutenance. - `git init` dans `swiftyCompanion/` quand tu pousses sur Vogosphere (voir README racine).
- Le dépôt perso peut versionner le code dans `swiftyCompanion/` ; le remote **école** pointe uniquement sur ce sous-dossier (voir README racine). - Bonus refresh token : étape 7 si tu as le temps après rendu.

View File

@@ -4,7 +4,9 @@ Parcours pour construire le projet **étape par étape**.
## Reprise rapide ## 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 ## 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) | | **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) | | **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) | | **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** | | **5** | Navigation retour + layout responsive | ✅ **Codé** — [etape-05-navigation-layout.md](etape-05-navigation-layout.md) |
| **6** | Tests manuels + checklist peer-eval | ⏳ | | **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 | ⏳ | | **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) 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-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-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-04-vue-profil.md](etape-04-vue-profil.md) | ✅ Guide étape 4 |
| `etape-05-navigation-layout.md` | 🔜 | | [etape-05-navigation-layout.md](etape-05-navigation-layout.md) | ✅ Guide étape 5 |
| `etape-06-bonus-token.md` | 🔜 | | [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. Demande dans le chat l’étape à ouvrir : le guide correspondant sera rédigé au moment où tu l’attaques.

View File

@@ -50,7 +50,6 @@ lib/
│ ├── intra_auth_service.dart # existant │ ├── intra_auth_service.dart # existant
│ └── intra_user_service.dart # GET user(s) avec token │ └── intra_user_service.dart # GET user(s) avec token
├── screens/ ├── screens/
│ ├── auth_screen.dart # option : fusionner ou garder login au démarrage
│ ├── search_screen.dart # VUE 1 │ ├── search_screen.dart # VUE 1
│ └── profile_screen.dart # VUE 2 (squelette → étape 4) │ └── profile_screen.dart # VUE 2 (squelette → étape 4)
└── main.dart # home → SearchScreen si token OK └── main.dart # home → SearchScreen si token OK

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. → 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 ## Éléments visuels du PDF

View File

@@ -2,37 +2,45 @@
Application mobile — profil étudiant 42 via l’**API Intra** (OAuth2). 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 ## Lancer le projet
```bash ```bash
cd swifty_companion cd swifty_companion
# Seul ce fichier compte pour Flutter (asset .env) : # Créer .env ici (voir .env.example à la racine du dépôt école)
# swifty_companion/.env
# Optionnel : copie de sauvegarde dans swiftyCompanion/.env — garder les deux identiques.
flutter pub get flutter pub get
flutter run -d <device> flutter run -d <device>
``` ```
É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 : Vérifications :
```bash ```bash
flutter doctor flutter doctor
flutter devices flutter devices
flutter analyze
flutter test
``` ```
## Configuration (`.env`) ## Configuration (`.env`)
1. Créer une app OAuth : [profile.intra.42.fr/oauth/applications](https://profile.intra.42.fr/oauth/applications) 1. 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` 2. Scopes : **`public`** + **`projects`** (projets sur le profil)
3. Ne **jamais** pousser `.env` sur le dépôt école (déjà ignoré par `.gitignore`) 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) ## 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** | | 1 | Au moins **2 vues** |
| 2 | **Toutes** les erreurs gérées (login inconnu, réseau, …) | | 2 | **Toutes** les erreurs gérées (login inconnu, réseau, …) |
| 3 | Vue 2 : infos du login s’il **existe** | | 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 | | 5 | **Skills** : niveau + pourcentage |
| 6 | **Projets** réalisés, **y compris les failed** | | 6 | **Projets** réalisés, **y compris les failed** |
| 7 | Retour possible vers la **vue 1** | | 7 | Retour possible vers la **vue 1** |
| 8 | Layout **adaptatif** (tailles / plateformes) | | 8 | Layout **adaptatif** |
| 9 | **OAuth2 Intra** — **un token réutilisé**, pas un token par requête | | 9 | **OAuth2** — **un token** réutilisé |
Contraintes : dernière API 42 disponible ; secrets uniquement en local (`.env`). Certaines données peuvent être masquées par l’API (confidentialité).
Certaines données peuvent ne pas être affichées (confidentialité / réglementation).
## Bonus ## Bonus
- Rafraîchissement du token à expiration ; l’application reste fonctionnelle. - Refresh token : `IntraAuthService.refreshAccessToken()` + retry dans `intraApiGet` sur 401.
*(Bonus évalué seulement si la partie obligatoire est parfaite.)*
## Soutenance — checklist peer-eval ## Soutenance — checklist peer-eval
| # | Test | Démo | | # | Test | Démo |
|---|------|------| |---|------|------|
| 1 | `flutter test` | Tests OK (si présents) | | 1 | `flutter test` | Tests OK |
| 2 | `flutter run -d <device>` | Vue 1 → login valide → vue 2 | | 2 | `flutter run -d <device>` | Vue 1 → login valide → vue 2 |
| 3 | Profil | Photo + ≥ 4 champs + skills + projets (dont failed) | | 3 | Profil | Photo + ≥ 4 champs + skills + projets (dont failed) |
| 4 | Login invalide | Message clair, pas de crash | | 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 | | 7 | Petit écran / rotation | UI lisible |
| 8 | Bonus | Refresh token si implémenté | | 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 ## Fichiers de ce dépôt
| Fichier | Rôle | | Fichier | Rôle |
|---------|------| |---------|------|
| `README.md` | Ce document (correcteurs / peer-eval) | | `README.md` | Ce document |
| `.gitignore` | Règles Git pour le rendu école | | `.gitignore` | Exclut `.env`, build, etc. |
| `.env.example` | Modèle de variables (sans secrets) | | `.env.example` | Modèle sans secrets |
| `lib/`, `android/`, … | Code Flutter (après `flutter create` ou développement) | | `swifty_companion/` | Projet Flutter (`lib/`, `android/`, …) |

View File

@@ -17,8 +17,15 @@ class SwiftyCompanionApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'Swifty Companion', title: 'Swifty Companion',
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF00BABC)), colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF00BABC),
brightness: Brightness.light,
),
useMaterial3: true, useMaterial3: true,
appBarTheme: const AppBarTheme(
centerTitle: false,
elevation: 0,
),
), ),
home: const SearchScreen(), home: const SearchScreen(),
onUnknownRoute: (_) => MaterialPageRoute<void>( onUnknownRoute: (_) => MaterialPageRoute<void>(

View File

@@ -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<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _auth = IntraAuthService.instance;
bool _loading = false;
String? _error;
String? _login;
Future<void> _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,
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../services/intra_api_exception.dart'; import '../services/intra_api_exception.dart';
import '../services/intra_profile_service.dart'; import '../services/intra_profile_service.dart';
import '../theme/swifty_ui.dart';
/// Vue 2 — profil complet (étape 4). /// Vue 2 — profil complet (étape 4).
class ProfileScreen extends StatefulWidget { class ProfileScreen extends StatefulWidget {
@@ -79,18 +80,18 @@ class _ProfileScreenState extends State<ProfileScreen> {
final imageUrl = _imageUrl(user); final imageUrl = _imageUrl(user);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: SwiftyUi.appBar(title: login, showBack: true),
title: Text(login),
backgroundColor: const Color(0xFF00BABC),
foregroundColor: Colors.black,
),
body: _loading body: _loading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? _ErrorBody(message: _error!, onRetry: _load) ? ResponsiveScrollBody(
maxWidth: SwiftyUi.searchMaxWidth,
child: _ErrorBody(message: _error!, onRetry: _load),
)
: LayoutBuilder( : LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final wide = constraints.maxWidth >= 720; final wide =
constraints.maxWidth >= SwiftyUi.wideBreakpoint;
final profile = _profile!; final profile = _profile!;
final header = _ProfileHeader( final header = _ProfileHeader(
login: login, login: login,
@@ -104,51 +105,65 @@ class _ProfileScreenState extends State<ProfileScreen> {
final skills = _SkillsCard(skills: profile.skills); final skills = _SkillsCard(skills: profile.skills);
final projects = _ProjectsCard(projects: profile.projects); 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) { if (wide) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: SwiftyUi.pagePadding(constraints),
child: Row( child: Align(
crossAxisAlignment: CrossAxisAlignment.start, alignment: Alignment.topCenter,
children: [ child: ConstrainedBox(
Expanded( constraints: const BoxConstraints(
flex: 2, maxWidth: SwiftyUi.contentMaxWidth,
child: Column(
children: [
header,
const SizedBox(height: 16),
details,
],
),
), ),
const SizedBox(width: 24), child: Row(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
flex: 3, children: [
child: Column( Expanded(
children: [ flex: 2,
skills, child: Column(
const SizedBox(height: 16), children: [header, const SizedBox(height: 16), details],
projects, ),
], ),
), const SizedBox(width: 24),
Expanded(
flex: 3,
child: Column(
children: [
skills,
const SizedBox(height: 16),
projects,
],
),
),
],
), ),
], ),
), ),
); );
} }
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: SwiftyUi.pagePadding(constraints),
child: Column( child: Align(
crossAxisAlignment: CrossAxisAlignment.stretch, alignment: Alignment.topCenter,
children: [ child: ConstrainedBox(
header, constraints: const BoxConstraints(
const SizedBox(height: 16), maxWidth: SwiftyUi.contentMaxWidth,
details, ),
const SizedBox(height: 16), child: column,
skills, ),
const SizedBox(height: 16),
projects,
],
), ),
); );
}, },
@@ -165,20 +180,18 @@ class _ErrorBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Column(
padding: const EdgeInsets.all(24), mainAxisAlignment: MainAxisAlignment.center,
child: Column( children: [
mainAxisAlignment: MainAxisAlignment.center, const SizedBox(height: 48),
children: [ ErrorMessage(message: message),
Text( const SizedBox(height: 20),
message, LoadingFilledButton(
textAlign: TextAlign.center, label: 'Réessayer',
style: TextStyle(color: Theme.of(context).colorScheme.error), loading: false,
), onPressed: onRetry,
const SizedBox(height: 16), ),
FilledButton(onPressed: onRetry, child: const Text('Réessayer')), ],
],
),
); );
} }
} }
@@ -269,21 +282,39 @@ class _DetailRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return LayoutBuilder(
padding: const EdgeInsets.symmetric(vertical: 4), builder: (context, constraints) {
child: Row( final stacked = constraints.maxWidth < 400;
crossAxisAlignment: CrossAxisAlignment.start, if (stacked) {
children: [ return Padding(
SizedBox( padding: const EdgeInsets.symmetric(vertical: 6),
width: 120, child: Column(
child: Text( crossAxisAlignment: CrossAxisAlignment.start,
label, children: [
style: const TextStyle(fontWeight: FontWeight.w600), 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, value: (s.level + s.percentage / 100).clamp(0, 15) / 15,
minHeight: 6, minHeight: 6,
backgroundColor: Colors.grey.shade300, backgroundColor: Colors.grey.shade300,
color: const Color(0xFF00BABC), color: SwiftyUi.brand,
), ),
], ],
), ),
@@ -356,18 +387,25 @@ class _ProjectsCard extends StatelessWidget {
final statusLabel = final statusLabel =
p.status == 'failed' ? 'Échec' : 'Validé'; p.status == 'failed' ? 'Échec' : 'Validé';
final mark = p.finalMark != null ? ' · ${p.finalMark}' : ''; final mark = p.finalMark != null ? ' · ${p.finalMark}' : '';
return ListTile( return Padding(
dense: true, padding: const EdgeInsets.symmetric(vertical: 4),
contentPadding: EdgeInsets.zero, child: Row(
title: Text(p.name), crossAxisAlignment: CrossAxisAlignment.start,
trailing: Text( children: [
'$statusLabel$mark', Expanded(
style: TextStyle( child: Text(p.name),
color: p.status == 'failed' ),
? Theme.of(context).colorScheme.error const SizedBox(width: 8),
: Colors.green.shade700, Text(
fontWeight: FontWeight.w600, '$statusLabel$mark',
), style: TextStyle(
color: p.status == 'failed'
? Theme.of(context).colorScheme.error
: Colors.green.shade700,
fontWeight: FontWeight.w600,
),
),
],
), ),
); );
}), }),

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_appauth/flutter_appauth.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_api_exception.dart';
import '../services/intra_auth_service.dart'; import '../services/intra_auth_service.dart';
import '../services/intra_user_service.dart'; import '../services/intra_user_service.dart';
import '../theme/swifty_ui.dart';
import 'profile_screen.dart'; import 'profile_screen.dart';
/// Vue 1 — recherche par login 42. /// Vue 1 — recherche par login 42.
@@ -106,9 +108,9 @@ class _SearchScreenState extends State<SearchScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!IntraConfig.isConfigured) { if (!IntraConfig.isConfigured) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Swifty Companion')), appBar: SwiftyUi.appBar(title: 'Swifty Companion'),
body: const Padding( body: const ResponsiveScrollBody(
padding: EdgeInsets.all(24), maxWidth: SwiftyUi.searchMaxWidth,
child: Text( child: Text(
'Fichier .env manquant dans swifty_companion/.env', 'Fichier .env manquant dans swifty_companion/.env',
), ),
@@ -117,55 +119,50 @@ class _SearchScreenState extends State<SearchScreen> {
} }
final isAuthenticated = _auth.isAuthenticated; final isAuthenticated = _auth.isAuthenticated;
final busy = _connecting || _searching;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: SwiftyUi.appBar(
title: const Text('Swifty Companion'), title: 'Swifty Companion',
backgroundColor: const Color(0xFF00BABC),
foregroundColor: Colors.black,
actions: [ actions: [
if (isAuthenticated) if (isAuthenticated)
IconButton( IconButton(
onPressed: _connecting || _searching ? null : _disconnect, onPressed: busy ? null : _disconnect,
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
tooltip: 'Déconnexion API', tooltip: 'Déconnexion API',
), ),
], ],
), ),
body: Padding( body: ResponsiveScrollBody(
padding: const EdgeInsets.all(24), maxWidth: SwiftyUi.searchMaxWidth,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (!isAuthenticated) ...[ if (!isAuthenticated) ...[
const Text( Text(
'Connexion API 42', 'Connexion API 42',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
'Connecte-toi une fois ; le token servira pour toutes les recherches.', 'Connecte-toi une fois ; le token servira pour toutes les recherches.',
style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 20),
FilledButton( LoadingFilledButton(
onPressed: _connecting ? null : _connect, label: 'Se connecter avec 42',
child: _connecting loading: _connecting,
? const SizedBox( onPressed: _connect,
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Se connecter avec 42'),
), ),
] else ...[ ] else ...[
Text( Text(
'Session : ${_sessionLogin ?? 'connecté'}', 'Session : ${_sessionLogin ?? 'connecté'}',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 24), const SizedBox(height: 28),
const Text( Text(
'Rechercher un étudiant', 'Rechercher un étudiant',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
@@ -176,29 +173,40 @@ class _SearchScreenState extends State<SearchScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
autocorrect: false,
enableSuggestions: false,
onSubmitted: (_) { onSubmitted: (_) {
if (!_searching) _search(); if (!_searching) _search();
}, },
enabled: !_searching, enabled: !_searching,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( LoadingFilledButton(
onPressed: _searching ? null : _search, label: 'Rechercher',
child: _searching loading: _searching,
? const SizedBox( onPressed: _search,
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Rechercher'),
), ),
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) ...[ if (_error != null) ...[
const SizedBox(height: 16), const SizedBox(height: 20),
Text( ErrorMessage(message: _error!),
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
], ],
], ],
), ),

View File

@@ -1,11 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../config/intra_config.dart'; 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 { class IntraAuthService {
IntraAuthService._(); IntraAuthService._();
static final IntraAuthService instance = IntraAuthService._(); static final IntraAuthService instance = IntraAuthService._();
@@ -13,9 +16,13 @@ class IntraAuthService {
final FlutterAppAuth _appAuth = const FlutterAppAuth(); final FlutterAppAuth _appAuth = const FlutterAppAuth();
String? _accessToken; String? _accessToken;
String? _refreshToken;
Future<bool>? _refreshInFlight;
String? get accessToken => _accessToken; String? get accessToken => _accessToken;
bool get isAuthenticated => _accessToken != null && _accessToken!.isNotEmpty; bool get isAuthenticated => _accessToken != null && _accessToken!.isNotEmpty;
bool get canRefresh =>
_refreshToken != null && _refreshToken!.isNotEmpty;
static final AuthorizationServiceConfiguration _serviceConfiguration = static final AuthorizationServiceConfiguration _serviceConfiguration =
AuthorizationServiceConfiguration( AuthorizationServiceConfiguration(
@@ -36,20 +43,75 @@ class IntraAuthService {
IntraConfig.redirectUri, IntraConfig.redirectUri,
clientSecret: IntraConfig.clientSecret, clientSecret: IntraConfig.clientSecret,
serviceConfiguration: _serviceConfiguration, serviceConfiguration: _serviceConfiguration,
// `projects` requis pour projects_users ; skills via cursus_users (pas /experiences).
scopes: ['public', 'projects'], scopes: ['public', 'projects'],
), ),
); );
_accessToken = result.accessToken; _applyOAuthResult(result.accessToken, result.refreshToken);
if (!isAuthenticated) { if (!isAuthenticated) {
throw StateError('Connexion 42 annulée ou token absent'); 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() { void signOut() {
_accessToken = null; _accessToken = null;
_refreshToken = null;
_refreshInFlight = null;
}
/// Rafraîchit l’access token (bonus). Retourne `false` si impossible.
Future<bool> 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<bool> _refreshAccessTokenOnce() async {
if (!canRefresh) return false;
final response = await http.post(
Uri.parse(IntraConfig.tokenUrl),
headers: const {'Accept': 'application/json'},
body: <String, String>{
'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<String, dynamic>) 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`. /// Header Authorization pour les appels `api.intra.42.fr`.
@@ -64,15 +126,23 @@ class IntraAuthService {
} }
Future<Map<String, dynamic>> fetchMe() async { Future<Map<String, dynamic>> fetchMe() async {
final response = await http.get( final response = await intraApiGet(
Uri.parse('${IntraConfig.apiBaseUrl}/me'), 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<String, dynamic>; return jsonDecode(response.body) as Map<String, dynamic>;
} }
@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';
}
} }

View File

@@ -3,15 +3,26 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'intra_api_exception.dart'; 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<http.Response> intraApiGet( Future<http.Response> intraApiGet(
Uri uri, { Uri uri, {
required Map<String, String> headers, required IntraAuthService auth,
int maxAttempts = 4, int maxAttempts = 4,
}) async { }) async {
var retriedAfterRefresh = false;
for (var attempt = 0; attempt < maxAttempts; attempt++) { 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 (response.statusCode == 429) {
if (attempt + 1 >= maxAttempts) { if (attempt + 1 >= maxAttempts) {
@@ -35,7 +46,6 @@ Duration _retryDelay(http.Response response, int attempt) {
return Duration(seconds: seconds.clamp(1, 30)); return Duration(seconds: seconds.clamp(1, 30));
} }
} }
// 0.8s, 1.6s, 3.2s…
final ms = 800 * (1 << attempt); final ms = 800 * (1 << attempt);
return Duration(milliseconds: ms.clamp(800, 8000)); return Duration(milliseconds: ms.clamp(800, 8000));
} }

View File

@@ -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<String, dynamic> 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,
);
}

View File

@@ -132,10 +132,7 @@ class IntraProfileService {
final uri = Uri.parse('${IntraConfig.apiBaseUrl}$path').replace( final uri = Uri.parse('${IntraConfig.apiBaseUrl}$path').replace(
queryParameters: query, queryParameters: query,
); );
final response = await intraApiGet( final response = await intraApiGet(uri, auth: _auth);
uri,
headers: _auth.authHeaders(),
);
return decodeIntraBody(response); return decodeIntraBody(response);
} }

View File

@@ -29,10 +29,7 @@ class IntraUserService {
); );
try { try {
final response = await intraApiGet( final response = await intraApiGet(uri, auth: _auth);
uri,
headers: _auth.authHeaders(),
);
return _parseUserListResponse(response, trimmed); return _parseUserListResponse(response, trimmed);
} on IntraApiException { } on IntraApiException {
rethrow; rethrow;

View File

@@ -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<Widget>? 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),
);
}
}

View File

@@ -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);
});
});
}