etape4
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Reprise du projet — où nous en sommes
|
# Reprise du projet — où nous en sommes
|
||||||
|
|
||||||
> Dernière mise à jour : projet Flutter créé dans `swiftyCompanion/swifty_companion/`.
|
> Dernière mise à jour : étape 4 codée (profil complet).
|
||||||
|
|
||||||
## En bref
|
## En bref
|
||||||
|
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **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é) |
|
||||||
| **Prochaine action** | **Étape 4** — [vue profil complète](etape-04-vue-profil.md) |
|
| **Fait** | Vue 1 recherche + vue 2 profil (skills, projets, ≥ 4 champs) |
|
||||||
|
| **Prochaine action** | **Étape 5** — retour / layout + tests manuels sur device |
|
||||||
|
|
||||||
## Arborescence actuelle
|
## Arborescence actuelle
|
||||||
|
|
||||||
@@ -27,7 +28,6 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso)
|
|||||||
│ └── 00-reprise-et-etat.md ← ce fichier
|
│ └── 00-reprise-et-etat.md ← ce fichier
|
||||||
└── swiftyCompanion/ ← futur dépôt ÉCOLE (racine Vogosphere)
|
└── swiftyCompanion/ ← futur dépôt ÉCOLE (racine Vogosphere)
|
||||||
├── README.md
|
├── README.md
|
||||||
├── .gitignore
|
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── swifty_companion/ ← projet Flutter (lib/, android/, pubspec.yaml)
|
└── swifty_companion/ ← projet Flutter (lib/, android/, pubspec.yaml)
|
||||||
```
|
```
|
||||||
@@ -35,9 +35,10 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso)
|
|||||||
## Décisions déjà prises
|
## Décisions déjà prises
|
||||||
|
|
||||||
1. **Deux dépôts Git** : tout `docs/` et la traduction restent dans le repo perso ; l’Intra ne reçoit que le contenu de `swiftyCompanion/`.
|
1. **Deux dépôts Git** : tout `docs/` et la traduction restent dans le repo perso ; l’Intra ne reçoit que le contenu de `swiftyCompanion/`.
|
||||||
2. **Stack prévue** : Flutter (cohérent avec `piscine_mobile/`), modifiable en soutenance si justifié.
|
2. **Stack** : Flutter, `flutter_dotenv`, `flutter_appauth`, `http`.
|
||||||
3. **Secrets** : `.env` local uniquement, jamais commité (modèle dans `swiftyCompanion/.env.example`).
|
3. **Secrets** : `.env` local uniquement, jamais commité (modèle dans `swiftyCompanion/.env.example`).
|
||||||
4. **Sujet PDF** : pas de maquettes d’écrans — l’UI suit la liste des critères obligatoires ([sujet FR](../sujets/swifty-companion.md)).
|
4. **Redirect OAuth mobile** : `com.example.swiftycompanion://callback` (pas `localhost` sur téléphone physique).
|
||||||
|
5. **Sujet PDF** : pas de maquettes d’écrans — l’UI suit la liste des critères obligatoires ([sujet FR](../sujets/swifty-companion.md)).
|
||||||
|
|
||||||
## Avancement par étape
|
## Avancement par étape
|
||||||
|
|
||||||
@@ -45,25 +46,37 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso)
|
|||||||
|-------|---------|--------|
|
|-------|---------|--------|
|
||||||
| **0** | Structure, traduction sujet, séparation travail / école, README rendu | ✅ **Terminé** |
|
| **0** | Structure, traduction sujet, séparation travail / école, README rendu | ✅ **Terminé** |
|
||||||
| **1** | Projet Flutter `swiftyCompanion/swifty_companion/` | ✅ **Terminé** |
|
| **1** | Projet Flutter `swiftyCompanion/swifty_companion/` | ✅ **Terminé** |
|
||||||
| **2** | OAuth2 (`IntraAuthService`, `/v2/me`) | ✅ **Validé** (`fgras-ca`) |
|
| **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 : profil complet (≥ 4 champs, skills, projets) | ⏳ **Prochaine** |
|
| **4** | Vue 2 : `IntraProfileService` + profil (skills, projets, champs) | ✅ **Codé** — tester sur téléphone |
|
||||||
| **5** | Retour vue 1 + layout adaptatif | ⏳ |
|
| **5** | Retour vue 1 + layout adaptatif (polish) | ⏳ **Prochaine** |
|
||||||
| **6** | Passage checklist [swiftyCompanion/README.md](../../swiftyCompanion/README.md) | ⏳ |
|
| **6** | Passage checklist [swiftyCompanion/README.md](../../swiftyCompanion/README.md) | ⏳ |
|
||||||
| **7** | Bonus : refresh token à expiration | ⏳ |
|
| **7** | Bonus : refresh token à expiration | ⏳ |
|
||||||
|
|
||||||
|
## 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_user_service.dart` | `GET /v2/users?filter[login]=` |
|
||||||
|
| `services/intra_profile_service.dart` | Profil : user, cursus, experiences, projects_users |
|
||||||
|
| `services/intra_api_exception.dart` | Erreurs métier |
|
||||||
|
| `screens/search_screen.dart` | **Vue 1** |
|
||||||
|
| `screens/profile_screen.dart` | **Vue 2** |
|
||||||
|
| `main.dart` | `home: SearchScreen()` |
|
||||||
|
|
||||||
## Reprendre le travail (checklist)
|
## Reprendre le travail (checklist)
|
||||||
|
|
||||||
1. Ouvrir le dépôt : `~/projets/42School/swifty_companion`
|
1. Ouvrir le dépôt : `~/projets/42School/swifty_companion`
|
||||||
2. Relire le [sujet traduit](../sujets/swifty-companion.md) (section obligatoire).
|
2. Relire le [sujet traduit](../sujets/swifty-companion.md) (section obligatoire).
|
||||||
3. Vérifier Flutter : `flutter doctor` et `flutter devices` (voir [piscine — Ubuntu natif](../../../piscine_mobile/docs/pedagogie/flutter/09-ubuntu-natif-dev-android.md) si besoin).
|
3. Vérifier Flutter : `flutter doctor` et `flutter devices`.
|
||||||
4. Lancer l’app :
|
4. Lancer l’app (après modification `.env`, relancer `flutter run`) :
|
||||||
```bash
|
```bash
|
||||||
cd swiftyCompanion/swifty_companion
|
cd swiftyCompanion/swifty_companion
|
||||||
flutter run -d <device>
|
flutter run -d <device>
|
||||||
```
|
```
|
||||||
5. **Étape 2** — suivre [etape-02-oauth-intra.md](etape-02-oauth-intra.md) (app OAuth Intra, `.env`, service token unique).
|
5. Tester le parcours : OAuth → recherche → profil → retour → erreurs réseau.
|
||||||
6. Dans le chat : demander l’**implémentation pas à pas** du code OAuth quand l’app OAuth Intra est créée.
|
|
||||||
|
|
||||||
## Fichiers utiles selon le contexte
|
## Fichiers utiles selon le contexte
|
||||||
|
|
||||||
@@ -71,23 +84,22 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso)
|
|||||||
|----------|---------|
|
|----------|---------|
|
||||||
| 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) |
|
||||||
| Workflow Git travail / école | [README racine](../../README.md) |
|
| Workflow Git travail / école | [README racine](../../README.md) |
|
||||||
| Soutenance / peer-eval | [swiftyCompanion/README.md](../../swiftyCompanion/README.md) |
|
| Soutenance / peer-eval | [swiftyCompanion/README.md](../../swiftyCompanion/README.md) |
|
||||||
| Environnement Flutter | [piscine_mobile](../../../piscine_mobile/README.md) |
|
|
||||||
|
|
||||||
## Guides détaillés (à créer au fil du code)
|
## Guides détaillés
|
||||||
|
|
||||||
| Fichier prévu | Quand |
|
| Fichier | Statut |
|
||||||
|---------------|--------|
|
|---------|--------|
|
||||||
| `etape-01-flutter-init.md` | Après validation étape 1 |
|
| [etape-02-oauth-intra.md](etape-02-oauth-intra.md) | ✅ |
|
||||||
| `etape-02-oauth.md` | Début OAuth Intra |
|
| [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | ✅ |
|
||||||
| `etape-03-vue-recherche.md` | Vue 1 |
|
| [etape-04-vue-profil.md](etape-04-vue-profil.md) | ✅ |
|
||||||
| `etape-04-vue-profil.md` | Vue 2 + API |
|
| `etape-05-navigation-layout.md` | 🔜 |
|
||||||
| `etape-05-navigation-layout.md` | Navigation + responsive |
|
| `etape-06-bonus-token.md` | 🔜 |
|
||||||
| `etape-06-bonus-token.md` | Bonus |
|
|
||||||
|
|
||||||
## Notes pour la session suivante
|
## Notes pour la session suivante
|
||||||
|
|
||||||
- Code : projet Flutter par défaut (`lib/main.dart` compteur) — OAuth et vues à implémenter.
|
- 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).
|
- Le dépôt perso peut versionner le code dans `swiftyCompanion/` ; le remote **école** pointe uniquement sur ce sous-dossier (voir README racine).
|
||||||
- Aucun `git init` obligatoire dans `swiftyCompanion/` tant que tu n’es pas prêt à lier Vogosphere.
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 1 : `flutter create`**).
|
→ **[00-reprise-et-etat.md](00-reprise-et-etat.md)** — état actuel, arborescence, prochaine action (**étape 5 : layout / tests**).
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
@@ -17,13 +17,12 @@ Base via la [piscine mobile](../../../piscine_mobile/README.md) sur le même dis
|
|||||||
|
|
||||||
| Étape | Objectif | Statut |
|
| Étape | Objectif | Statut |
|
||||||
|-------|----------|--------|
|
|-------|----------|--------|
|
||||||
| **0** | Dépôt travail + `docs/`, sujet FR, paquet `swiftyCompanion/` (README / `.gitignore` / `.env.example`) | ✅ **Fait** — détail dans [00-reprise-et-etat.md](00-reprise-et-etat.md) |
|
| **0** | Dépôt travail + `docs/`, sujet FR, paquet `swiftyCompanion/` | ✅ **Fait** — [00-reprise-et-etat.md](00-reprise-et-etat.md) |
|
||||||
| **1** | Projet `swiftyCompanion/swifty_companion/` + `flutter run` | ✅ **Fait** |
|
| **1** | Projet `swiftyCompanion/swifty_companion/` + `flutter run` | ✅ **Fait** |
|
||||||
| **2** | OAuth2 Intra : token + `/v2/me` | ✅ Fait |
|
| **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é → tester · [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 complet | ⏳ **Prochaine** → [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) |
|
||||||
| **4** | **Vue 2** : profil (≥ 4 champs + image), skills, projets (validés + failed) | ⏳ |
|
| **5** | Navigation retour + layout responsive (polish) | ⏳ **Prochaine** |
|
||||||
| **5** | Navigation retour + layout responsive | ⏳ |
|
|
||||||
| **6** | Tests manuels + checklist peer-eval | ⏳ |
|
| **6** | Tests manuels + checklist peer-eval | ⏳ |
|
||||||
| **7** | **Bonus** : refresh token à l’expiration | ⏳ |
|
| **7** | **Bonus** : refresh token à l’expiration | ⏳ |
|
||||||
|
|
||||||
@@ -40,9 +39,8 @@ README correcteurs (dépôt école) : [swiftyCompanion/README.md](../../swiftyCo
|
|||||||
| [00-reprise-et-etat.md](00-reprise-et-etat.md) | ✅ À jour |
|
| [00-reprise-et-etat.md](00-reprise-et-etat.md) | ✅ À jour |
|
||||||
| [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) | 🔜 après étape 3 |
|
| [etape-04-vue-profil.md](etape-04-vue-profil.md) | ✅ Guide étape 4 |
|
||||||
| `etape-04-vue-profil.md` | 🔜 |
|
|
||||||
| `etape-05-navigation-layout.md` | 🔜 |
|
| `etape-05-navigation-layout.md` | 🔜 |
|
||||||
| `etape-06-bonus-token.md` | 🔜 |
|
| `etape-06-bonus-token.md` | 🔜 |
|
||||||
|
|
||||||
Demande dans le chat l’étape à ouvrir : le guide correspondant sera rédigé au moment où tu la attaques.
|
Demande dans le chat l’étape à ouvrir : le guide correspondant sera rédigé au moment où tu l’attaques.
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Endpoints 42 (référence) :
|
|||||||
| Token | `POST https://api.intra.42.fr/oauth/token` |
|
| Token | `POST https://api.intra.42.fr/oauth/token` |
|
||||||
| API | `GET https://api.intra.42.fr/v2/...` avec header `Authorization: Bearer <token>` |
|
| API | `GET https://api.intra.42.fr/v2/...` avec header `Authorization: Bearer <token>` |
|
||||||
|
|
||||||
Paramètres authorize usuels : `client_id`, `redirect_uri`, `response_type=code`, `scope=public`.
|
Paramètres authorize usuels : `client_id`, `redirect_uri`, `response_type=code`, `scope=public projects` (voir [intra-oauth-formulaire.md](intra-oauth-formulaire.md)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,113 @@
|
|||||||
# Étape 4 — Vue 2 : profil complet
|
# Étape 4 — Vue 2 : profil complet
|
||||||
|
|
||||||
> **Prérequis :** [étape 3 — recherche](etape-03-vue-recherche.md) (navigation vers vue 2 avec login valide).
|
> **Prérequis :** [étape 3 — recherche](etape-03-vue-recherche.md) (navigation vers vue 2 avec un login valide).
|
||||||
|
|
||||||
## Objectif sujet (partie obligatoire)
|
## Objectif sujet
|
||||||
|
|
||||||
| Exigence | Détail |
|
| Exigence | Cette étape |
|
||||||
|----------|--------|
|
|----------|-------------|
|
||||||
| ≥ 4 infos | login, email, mobile, level, location, wallet, evaluations, … |
|
| ≥ 4 infos + photo | login (titre / avatar), email, téléphone, niveau cursus, localisation, wallet |
|
||||||
| Photo | Image de profil (`image_url` / `image.link` selon API) |
|
| Skills | Niveau entier + **pourcentage** (partie décimale du `level` API) |
|
||||||
| Skills | Niveau + pourcentage |
|
| Projets | Statuts **`finished`** et **`failed`** |
|
||||||
| Projets | Terminés **y compris failed** |
|
| Erreurs | Réseau, 401, réponse API inattendue — message + réessai |
|
||||||
| Retour | Bouton retour vers vue 1 |
|
| Token | Un seul OAuth : `IntraAuthService` pour tous les `GET` |
|
||||||
| Layout | Responsive (étape 5 peut affiner) |
|
| Retour vue 1 | `AppBar` + bouton retour système (`Navigator.pop`) |
|
||||||
|
| Layout | `LayoutBuilder` : colonne sur téléphone, 2 colonnes si largeur ≥ 720 px |
|
||||||
|
|
||||||
## API
|
---
|
||||||
|
|
||||||
- `GET /v2/users/{id}` avec champs / includes selon apidoc (`cursus_users`, `projects_users`, `skills`, etc.).
|
## Parcours utilisateur (vue 2)
|
||||||
- Token : toujours via `IntraAuthService`.
|
|
||||||
|
|
||||||
Guide détaillé à rédiger quand l’étape 3 est codée — demande l’implémentation pas à pas dans le chat.
|
1. Arrivée depuis la recherche avec un objet `user` minimal (`id`, `login`, parfois `image`).
|
||||||
|
2. **Chargement** : appels API en parallèle (spinner plein écran).
|
||||||
|
3. **Succès** : photo, carte « Informations », liste skills, liste projets.
|
||||||
|
4. **Échec** : message rouge + bouton **Réessayer**.
|
||||||
|
5. **Retour** : flèche AppBar → vue 1, nouvelle recherche possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 42 utilisée
|
||||||
|
|
||||||
|
Doc : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
|
||||||
|
|
||||||
|
| Donnée | Endpoint | Notes |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| Fiche user | *(déjà renvoyée par la recherche étape 3)* | email, phone, location, wallet, image — pas de 2ᵉ `GET /users/{id}` pour limiter le quota |
|
||||||
|
| Fiche complète | `GET /v2/users/{login}` | inclut souvent `cursus_users` + `skills` par cursus |
|
||||||
|
| Niveau / skills | `cursus_users[].skills` | pas `/experiences` (souvent **403** avec token étudiant) |
|
||||||
|
| Projets | `GET /v2/users/{id}/projects_users?page[size]=100` | scope OAuth **`projects`** ; filtrer côté app : `finished` / `failed` |
|
||||||
|
|
||||||
|
Tous les appels :
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Confidentialité :** certains champs peuvent être masqués par l’API selon le profil consulté — afficher `—` si absent (sujet OAuth).
|
||||||
|
|
||||||
|
### Skills — niveau et pourcentage
|
||||||
|
|
||||||
|
L’API renvoie un `level` flottant (ex. `8.42`) :
|
||||||
|
|
||||||
|
- **Niveau affiché** : partie entière (`8`)
|
||||||
|
- **Pourcentage** : partie décimale × 100 (`42 %`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers (étape 4)
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── services/
|
||||||
|
│ ├── intra_auth_service.dart # existant — headers Bearer
|
||||||
|
│ ├── intra_user_service.dart # existant — recherche
|
||||||
|
│ └── intra_profile_service.dart # NEW — agrégation profil
|
||||||
|
└── screens/
|
||||||
|
└── profile_screen.dart # StatefulWidget — chargement + UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### `IntraProfileService`
|
||||||
|
|
||||||
|
- `fetchProfile(Map<String, dynamic> summaryUser)` → `IntraUserProfile`
|
||||||
|
- Requêtes **séquentielles** (pause ~450 ms) + retry sur 429 (`intra_http.dart`)
|
||||||
|
- Cache profil 2 min par `user_id` pour éviter de re-taper l’API au retour arrière
|
||||||
|
- Mêmes exceptions que l’étape 3 : `IntraNetworkException`, `IntraUnauthorizedException`, etc.
|
||||||
|
|
||||||
|
### `ProfileScreen`
|
||||||
|
|
||||||
|
- Reçoit le `user` de l’étape 3
|
||||||
|
- `initState` → `_load()`
|
||||||
|
- Pas de second OAuth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestion des erreurs (vue 2)
|
||||||
|
|
||||||
|
| Situation | Comportement |
|
||||||
|
|-----------|----------------|
|
||||||
|
| Pas de réseau | Message + **Réessayer** |
|
||||||
|
| Token expiré (401) | « Session expirée… » — reconnecter depuis vue 1 |
|
||||||
|
| Trop de requêtes (429) | Message dédié ; retry automatique côté client + attendre avant de réessayer |
|
||||||
|
| Accès refusé (403) | Scopes `public` + `projects` sur l’app OAuth, puis **reconnexion** dans l’app |
|
||||||
|
| ID manquant | Erreur métier explicite |
|
||||||
|
| Listes vides skills / projets | Texte informatif, pas de crash |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères « étape 4 terminée »
|
||||||
|
|
||||||
|
| # | Test |
|
||||||
|
|---|------|
|
||||||
|
| 1 | Login valide → photo + au moins 4 champs remplis ou `—` |
|
||||||
|
| 2 | Section skills avec niveau **et** pourcentage |
|
||||||
|
| 3 | Au moins un projet `finished` ou `failed` visible (si l’étudiant en a) |
|
||||||
|
| 4 | Mode avion sur vue 2 → erreur + réessai |
|
||||||
|
| 5 | Retour → vue 1 sans perdre la session OAuth |
|
||||||
|
| 6 | Largeur tablette / fenêtre large → mise en page 2 colonnes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ensuite — étape 5
|
||||||
|
|
||||||
|
→ Affiner navigation, contraintes et polish responsive sur **les deux vues** (`etape-05-navigation-layout.md` à rédiger).
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ http://localhost:8080/callback
|
|||||||
|
|
||||||
## Champs d’application (scopes)
|
## Champs d’application (scopes)
|
||||||
|
|
||||||
Cocher **uniquement** :
|
Cocher :
|
||||||
|
|
||||||
- [x] **Accéder aux données publiques de l'utilisateur** (`public`)
|
- [x] **Accéder aux données publiques de l'utilisateur** (`public`)
|
||||||
|
- [x] **Accéder aux données des projets de l'utilisateur** (`projects`) — requis pour `projects_users` (sinon **403** sur le profil)
|
||||||
|
|
||||||
Laisser **décoché** : équipes / projets, données utilisateur, médias, communauté, sujets.
|
Laisser **décoché** le reste sauf besoin explicite (équipes, médias, forum, etc.).
|
||||||
|
|
||||||
|
Après ajout du scope `projects`, **déconnexion puis nouvelle connexion** dans l’app pour obtenir un token à jour.
|
||||||
|
|
||||||
## Après création
|
## Après création
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 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.
|
||||||
|
|
||||||
**État :** projet Flutter dans le sous-dossier `swifty_companion/`. Prochaine étape : OAuth Intra (dépôt de travail : `docs/pedagogie/etape-02-oauth-intra.md`).
|
**É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`).
|
||||||
|
|
||||||
## Lancer le projet
|
## Lancer le projet
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ flutter pub get
|
|||||||
flutter run -d <device>
|
flutter run -d <device>
|
||||||
```
|
```
|
||||||
|
|
||||||
Écran actuel : **connexion OAuth 42** puis affichage du login (`/v2/me`).
|
Écrans : **connexion OAuth 42** → **recherche par login** → **profil** (photo, champs, skills, projets validés / failed).
|
||||||
|
|
||||||
Vérifications :
|
Vérifications :
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,61 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Vue 2 — squelette étape 3 ; détails complets à l’étape 4.
|
import '../services/intra_api_exception.dart';
|
||||||
class ProfileScreen extends StatelessWidget {
|
import '../services/intra_profile_service.dart';
|
||||||
|
|
||||||
|
/// Vue 2 — profil complet (étape 4).
|
||||||
|
class ProfileScreen extends StatefulWidget {
|
||||||
const ProfileScreen({super.key, required this.user});
|
const ProfileScreen({super.key, required this.user});
|
||||||
|
|
||||||
|
/// Résultat minimal de la recherche (au moins `id` + `login`).
|
||||||
final Map<String, dynamic> user;
|
final Map<String, dynamic> user;
|
||||||
|
|
||||||
String? _imageUrl() {
|
@override
|
||||||
|
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
|
final _profiles = IntraProfileService.instance;
|
||||||
|
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
IntraUserProfile? _profile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final profile = await _profiles.fetchProfile(widget.user);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_profile = profile;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} on IntraApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.message;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _imageUrl(Map<String, dynamic> user) {
|
||||||
final image = user['image'];
|
final image = user['image'];
|
||||||
if (image is Map<String, dynamic>) {
|
if (image is Map<String, dynamic>) {
|
||||||
final link = image['link'];
|
final link = image['link'];
|
||||||
@@ -17,10 +66,17 @@ class ProfileScreen extends StatelessWidget {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _display(dynamic value, {String fallback = '—'}) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
final text = value.toString().trim();
|
||||||
|
return text.isEmpty ? fallback : text;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final login = user['login']?.toString() ?? '?';
|
final user = _profile?.user ?? widget.user;
|
||||||
final imageUrl = _imageUrl();
|
final login = _display(user['login'], fallback: '?');
|
||||||
|
final imageUrl = _imageUrl(user);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -28,16 +84,119 @@ class ProfileScreen extends StatelessWidget {
|
|||||||
backgroundColor: const Color(0xFF00BABC),
|
backgroundColor: const Color(0xFF00BABC),
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? _ErrorBody(message: _error!, onRetry: _load)
|
||||||
|
: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final wide = constraints.maxWidth >= 720;
|
||||||
|
final profile = _profile!;
|
||||||
|
final header = _ProfileHeader(
|
||||||
|
login: login,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
);
|
||||||
|
final details = _DetailsCard(
|
||||||
|
user: profile.user,
|
||||||
|
cursusLevel: profile.cursusLevel,
|
||||||
|
display: _display,
|
||||||
|
);
|
||||||
|
final skills = _SkillsCard(skills: profile.skills);
|
||||||
|
final projects = _ProjectsCard(projects: profile.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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
skills,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
projects,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Center(
|
header,
|
||||||
child: CircleAvatar(
|
const SizedBox(height: 16),
|
||||||
|
details,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
skills,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
projects,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorBody extends StatelessWidget {
|
||||||
|
const _ErrorBody({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileHeader extends StatelessWidget {
|
||||||
|
const _ProfileHeader({required this.login, required this.imageUrl});
|
||||||
|
|
||||||
|
final String login;
|
||||||
|
final String? imageUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
radius: 56,
|
radius: 56,
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
imageUrl != null ? NetworkImage(imageUrl) : null,
|
imageUrl != null ? NetworkImage(imageUrl!) : null,
|
||||||
child: imageUrl == null
|
child: imageUrl == null
|
||||||
? Text(
|
? Text(
|
||||||
login.isNotEmpty ? login[0].toUpperCase() : '?',
|
login.isNotEmpty ? login[0].toUpperCase() : '?',
|
||||||
@@ -45,22 +204,55 @@ class ProfileScreen extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
Text(
|
||||||
login,
|
login,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailsCard extends StatelessWidget {
|
||||||
|
const _DetailsCard({
|
||||||
|
required this.user,
|
||||||
|
required this.cursusLevel,
|
||||||
|
required this.display,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> user;
|
||||||
|
final double? cursusLevel;
|
||||||
|
final String Function(dynamic value, {String fallback}) display;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final levelText = cursusLevel != null
|
||||||
|
? cursusLevel!.toStringAsFixed(2)
|
||||||
|
: display(user['level']);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Étape 4 à venir : email, level, skills, projets (dont failed), etc.',
|
'Informations',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
Text(
|
_DetailRow(label: 'Email', value: display(user['email'])),
|
||||||
'ID Intra : ${user['id'] ?? '—'}',
|
_DetailRow(label: 'Téléphone', value: display(user['phone'])),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
_DetailRow(label: 'Niveau cursus', value: levelText),
|
||||||
|
_DetailRow(
|
||||||
|
label: 'Localisation',
|
||||||
|
value: display(user['location']),
|
||||||
|
),
|
||||||
|
_DetailRow(
|
||||||
|
label: 'Wallet',
|
||||||
|
value: display(user['wallet']),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -68,3 +260,120 @@ class ProfileScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DetailRow extends StatelessWidget {
|
||||||
|
const _DetailRow({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: Text(value)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SkillsCard extends StatelessWidget {
|
||||||
|
const _SkillsCard({required this.skills});
|
||||||
|
|
||||||
|
final List<IntraSkillSummary> skills;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Skills', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (skills.isEmpty)
|
||||||
|
const Text('Aucune compétence disponible.')
|
||||||
|
else
|
||||||
|
...skills.map(
|
||||||
|
(s) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('${s.name} — niveau ${s.level} (${s.percentage} %)'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: (s.level + s.percentage / 100).clamp(0, 15) / 15,
|
||||||
|
minHeight: 6,
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
color: const Color(0xFF00BABC),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProjectsCard extends StatelessWidget {
|
||||||
|
const _ProjectsCard({required this.projects});
|
||||||
|
|
||||||
|
final List<IntraProjectSummary> projects;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Projets (validés et failed)',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (projects.isEmpty)
|
||||||
|
const Text('Aucun projet terminé ou en échec.')
|
||||||
|
else
|
||||||
|
...projects.map((p) {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,3 +30,18 @@ class IntraUnauthorizedException extends IntraApiException {
|
|||||||
class IntraApiErrorException extends IntraApiException {
|
class IntraApiErrorException extends IntraApiException {
|
||||||
const IntraApiErrorException(super.message);
|
const IntraApiErrorException(super.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class IntraRateLimitException extends IntraApiException {
|
||||||
|
const IntraRateLimitException()
|
||||||
|
: super(
|
||||||
|
'Trop de requêtes vers l’API 42. Attends quelques secondes puis réessaie.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntraForbiddenException extends IntraApiException {
|
||||||
|
const IntraForbiddenException()
|
||||||
|
: super(
|
||||||
|
'Accès refusé (403). Déconnecte-toi puis reconnecte-toi '
|
||||||
|
'(scopes OAuth : public + projects sur l’app Intra).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ class IntraAuthService {
|
|||||||
IntraConfig.redirectUri,
|
IntraConfig.redirectUri,
|
||||||
clientSecret: IntraConfig.clientSecret,
|
clientSecret: IntraConfig.clientSecret,
|
||||||
serviceConfiguration: _serviceConfiguration,
|
serviceConfiguration: _serviceConfiguration,
|
||||||
scopes: ['public'],
|
// `projects` requis pour projects_users ; skills via cursus_users (pas /experiences).
|
||||||
|
scopes: ['public', 'projects'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'intra_api_exception.dart';
|
||||||
|
|
||||||
|
/// GET API 42 avec gestion 401 / 429 (retry + backoff).
|
||||||
|
Future<http.Response> intraApiGet(
|
||||||
|
Uri uri, {
|
||||||
|
required Map<String, String> headers,
|
||||||
|
int maxAttempts = 4,
|
||||||
|
}) async {
|
||||||
|
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
final response = await http.get(uri, headers: headers);
|
||||||
|
|
||||||
|
if (response.statusCode == 429) {
|
||||||
|
if (attempt + 1 >= maxAttempts) {
|
||||||
|
throw const IntraRateLimitException();
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(_retryDelay(response, attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw const IntraRateLimitException();
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration _retryDelay(http.Response response, int attempt) {
|
||||||
|
final header = response.headers['retry-after'];
|
||||||
|
if (header != null) {
|
||||||
|
final seconds = int.tryParse(header.trim());
|
||||||
|
if (seconds != null && seconds > 0) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ensureIntraOk(http.Response response) {
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
throw const IntraUnauthorizedException();
|
||||||
|
}
|
||||||
|
if (response.statusCode == 403) {
|
||||||
|
throw const IntraForbiddenException();
|
||||||
|
}
|
||||||
|
if (response.statusCode == 429) {
|
||||||
|
throw const IntraRateLimitException();
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw IntraApiErrorException(
|
||||||
|
'Erreur API (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic decodeIntraBody(http.Response response) {
|
||||||
|
ensureIntraOk(response);
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../config/intra_config.dart';
|
||||||
|
import 'intra_api_exception.dart';
|
||||||
|
import 'intra_auth_service.dart';
|
||||||
|
import 'intra_http.dart';
|
||||||
|
|
||||||
|
/// Compétence Intra : niveau entier + pourcentage (partie décimale du level API).
|
||||||
|
class IntraSkillSummary {
|
||||||
|
const IntraSkillSummary({
|
||||||
|
required this.name,
|
||||||
|
required this.level,
|
||||||
|
required this.percentage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final int level;
|
||||||
|
final int percentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projet terminé ou en échec.
|
||||||
|
class IntraProjectSummary {
|
||||||
|
const IntraProjectSummary({
|
||||||
|
required this.name,
|
||||||
|
required this.status,
|
||||||
|
this.finalMark,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String status;
|
||||||
|
final int? finalMark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Données agrégées pour la vue profil.
|
||||||
|
class IntraUserProfile {
|
||||||
|
const IntraUserProfile({
|
||||||
|
required this.user,
|
||||||
|
required this.cursusLevel,
|
||||||
|
required this.skills,
|
||||||
|
required this.projects,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> user;
|
||||||
|
final double? cursusLevel;
|
||||||
|
final List<IntraSkillSummary> skills;
|
||||||
|
final List<IntraProjectSummary> projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileCacheEntry {
|
||||||
|
_ProfileCacheEntry(this.profile, this.fetchedAt);
|
||||||
|
|
||||||
|
final IntraUserProfile profile;
|
||||||
|
final DateTime fetchedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntraProfileService {
|
||||||
|
IntraProfileService._();
|
||||||
|
static final IntraProfileService instance = IntraProfileService._();
|
||||||
|
|
||||||
|
static const Duration _cacheTtl = Duration(minutes: 2);
|
||||||
|
static const Duration _betweenRequests = Duration(milliseconds: 450);
|
||||||
|
|
||||||
|
final IntraAuthService _auth = IntraAuthService.instance;
|
||||||
|
final Map<String, _ProfileCacheEntry> _cache = {};
|
||||||
|
|
||||||
|
/// Charge profil complet (user, cursus, skills, projets) avec le token existant.
|
||||||
|
Future<IntraUserProfile> fetchProfile(Map<String, dynamic> summaryUser) async {
|
||||||
|
if (!_auth.isAuthenticated) {
|
||||||
|
throw const IntraNotAuthenticatedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
final userId = summaryUser['id'];
|
||||||
|
if (userId == null) {
|
||||||
|
throw const IntraApiErrorException('Identifiant utilisateur manquant');
|
||||||
|
}
|
||||||
|
final id = userId.toString();
|
||||||
|
|
||||||
|
final cached = _cache[id];
|
||||||
|
if (cached != null &&
|
||||||
|
DateTime.now().difference(cached.fetchedAt) < _cacheTtl) {
|
||||||
|
return cached.profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final login = summaryUser['login']?.toString();
|
||||||
|
final userPath = login != null && login.isNotEmpty
|
||||||
|
? '/users/$login'
|
||||||
|
: '/users/$id';
|
||||||
|
|
||||||
|
final userDecoded = await _getJson(userPath);
|
||||||
|
if (userDecoded is! Map<String, dynamic>) {
|
||||||
|
throw IntraApiErrorException('Réponse API inattendue ($userPath)');
|
||||||
|
}
|
||||||
|
|
||||||
|
final embeddedCursus = userDecoded['cursus_users'];
|
||||||
|
final List<dynamic> cursusList;
|
||||||
|
if (embeddedCursus is List && embeddedCursus.isNotEmpty) {
|
||||||
|
cursusList = embeddedCursus;
|
||||||
|
} else {
|
||||||
|
await Future<void>.delayed(_betweenRequests);
|
||||||
|
cursusList = await _getJsonList('/users/$id/cursus_users');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future<void>.delayed(_betweenRequests);
|
||||||
|
final projectsUsers = await _getJsonList(
|
||||||
|
'/users/$id/projects_users',
|
||||||
|
query: {'page[size]': '100'},
|
||||||
|
);
|
||||||
|
final primaryCursus = _pickPrimaryCursus(cursusList);
|
||||||
|
|
||||||
|
final profile = IntraUserProfile(
|
||||||
|
user: userDecoded,
|
||||||
|
cursusLevel: _levelFromCursus(primaryCursus),
|
||||||
|
skills: _parseSkillsFromCursus(primaryCursus, cursusList),
|
||||||
|
projects: _parseProjects(projectsUsers),
|
||||||
|
);
|
||||||
|
|
||||||
|
_cache[id] = _ProfileCacheEntry(profile, DateTime.now());
|
||||||
|
return profile;
|
||||||
|
} on IntraApiException {
|
||||||
|
rethrow;
|
||||||
|
} on SocketException {
|
||||||
|
throw const IntraNetworkException();
|
||||||
|
} on http.ClientException {
|
||||||
|
throw const IntraNetworkException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _getJson(String path, {Map<String, String>? query}) async {
|
||||||
|
final uri = Uri.parse('${IntraConfig.apiBaseUrl}$path').replace(
|
||||||
|
queryParameters: query,
|
||||||
|
);
|
||||||
|
final response = await intraApiGet(
|
||||||
|
uri,
|
||||||
|
headers: _auth.authHeaders(),
|
||||||
|
);
|
||||||
|
return decodeIntraBody(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<dynamic>> _getJsonList(
|
||||||
|
String path, {
|
||||||
|
Map<String, String>? query,
|
||||||
|
}) async {
|
||||||
|
final decoded = await _getJson(path, query: query);
|
||||||
|
if (decoded is! List) {
|
||||||
|
throw IntraApiErrorException('Réponse API inattendue ($path)');
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _pickPrimaryCursus(List<dynamic> cursusUsers) {
|
||||||
|
Map<String, dynamic>? fallback;
|
||||||
|
for (final entry in cursusUsers) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final cursus = entry['cursus'];
|
||||||
|
final slug = cursus is Map ? cursus['slug']?.toString() : null;
|
||||||
|
final name = cursus is Map ? cursus['name']?.toString() : null;
|
||||||
|
if (slug == '42' || name == '42' || name == '42cursus') {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
fallback ??= entry;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _levelFromCursus(Map<String, dynamic>? cursusUser) {
|
||||||
|
if (cursusUser == null) return null;
|
||||||
|
final level = cursusUser['level'];
|
||||||
|
return level is num ? level.toDouble() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<IntraSkillSummary> _parseSkillsFromCursus(
|
||||||
|
Map<String, dynamic>? primaryCursus,
|
||||||
|
List<dynamic> allCursus,
|
||||||
|
) {
|
||||||
|
final sources = <Map<String, dynamic>>[];
|
||||||
|
if (primaryCursus != null) {
|
||||||
|
sources.add(primaryCursus);
|
||||||
|
} else {
|
||||||
|
for (final c in allCursus) {
|
||||||
|
if (c is Map<String, dynamic>) sources.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final byId = <int, IntraSkillSummary>{};
|
||||||
|
for (final cursusUser in sources) {
|
||||||
|
final skills = cursusUser['skills'];
|
||||||
|
if (skills is! List) continue;
|
||||||
|
for (final skill in skills) {
|
||||||
|
if (skill is! Map<String, dynamic>) continue;
|
||||||
|
final idRaw = skill['id'];
|
||||||
|
if (idRaw is! num) continue;
|
||||||
|
final skillId = idRaw.toInt();
|
||||||
|
final name = skill['name']?.toString() ?? 'Skill $skillId';
|
||||||
|
final levelRaw = skill['level'];
|
||||||
|
if (levelRaw is! num) continue;
|
||||||
|
final summary = _skillFromLevel(name, levelRaw.toDouble());
|
||||||
|
final existing = byId[skillId];
|
||||||
|
if (existing == null || summary.level > existing.level) {
|
||||||
|
byId[skillId] = summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final list = byId.values.toList();
|
||||||
|
list.sort((a, b) => b.level.compareTo(a.level));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntraSkillSummary _skillFromLevel(String name, double levelValue) {
|
||||||
|
final whole = levelValue.floor();
|
||||||
|
final fraction = levelValue - whole;
|
||||||
|
final percentage = (fraction * 100).round().clamp(0, 99);
|
||||||
|
return IntraSkillSummary(
|
||||||
|
name: name,
|
||||||
|
level: whole,
|
||||||
|
percentage: percentage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<IntraProjectSummary> _parseProjects(List<dynamic> projectsUsers) {
|
||||||
|
final projects = <IntraProjectSummary>[];
|
||||||
|
|
||||||
|
for (final entry in projectsUsers) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final status = entry['status']?.toString();
|
||||||
|
if (status != 'finished' && status != 'failed') continue;
|
||||||
|
|
||||||
|
final project = entry['project'];
|
||||||
|
final name = project is Map
|
||||||
|
? (project['name']?.toString() ?? project['slug']?.toString())
|
||||||
|
: null;
|
||||||
|
if (name == null || name.isEmpty) continue;
|
||||||
|
|
||||||
|
final mark = entry['final_mark'];
|
||||||
|
projects.add(
|
||||||
|
IntraProjectSummary(
|
||||||
|
name: name,
|
||||||
|
status: status!,
|
||||||
|
finalMark: mark is int ? mark : (mark is num ? mark.round() : null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../config/intra_config.dart';
|
import '../config/intra_config.dart';
|
||||||
import 'intra_api_exception.dart';
|
import 'intra_api_exception.dart';
|
||||||
import 'intra_auth_service.dart';
|
import 'intra_auth_service.dart';
|
||||||
|
import 'intra_http.dart';
|
||||||
|
|
||||||
class IntraUserService {
|
class IntraUserService {
|
||||||
IntraUserService._();
|
IntraUserService._();
|
||||||
@@ -28,7 +29,10 @@ class IntraUserService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http.get(uri, headers: _auth.authHeaders());
|
final response = await intraApiGet(
|
||||||
|
uri,
|
||||||
|
headers: _auth.authHeaders(),
|
||||||
|
);
|
||||||
return _parseUserListResponse(response, trimmed);
|
return _parseUserListResponse(response, trimmed);
|
||||||
} on IntraApiException {
|
} on IntraApiException {
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -43,14 +47,7 @@ class IntraUserService {
|
|||||||
http.Response response,
|
http.Response response,
|
||||||
String login,
|
String login,
|
||||||
) {
|
) {
|
||||||
if (response.statusCode == 401) {
|
ensureIntraOk(response);
|
||||||
throw const IntraUnauthorizedException();
|
|
||||||
}
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw IntraApiErrorException(
|
|
||||||
'Erreur API (${response.statusCode})',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! List) {
|
if (decoded is! List) {
|
||||||
|
|||||||
Reference in New Issue
Block a user