From 4de305bbf4d0585938892fef252715c058bdc0fc Mon Sep 17 00:00:00 2001 From: Ladebeze66 Date: Tue, 29 Sep 2026 15:07:05 +0200 Subject: [PATCH] etape4 --- docs/pedagogie/00-reprise-et-etat.md | 62 +-- docs/pedagogie/README.md | 18 +- docs/pedagogie/etape-02-oauth-intra.md | 2 +- docs/pedagogie/etape-04-vue-profil.md | 120 +++++- docs/pedagogie/intra-oauth-formulaire.md | 7 +- swiftyCompanion/README.md | 4 +- .../lib/screens/profile_screen.dart | 375 ++++++++++++++++-- .../lib/services/intra_api_exception.dart | 15 + .../lib/services/intra_auth_service.dart | 3 +- .../lib/services/intra_http.dart | 63 +++ .../lib/services/intra_profile_service.dart | 250 ++++++++++++ .../lib/services/intra_user_service.dart | 15 +- 12 files changed, 837 insertions(+), 97 deletions(-) create mode 100644 swiftyCompanion/swifty_companion/lib/services/intra_http.dart create mode 100644 swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart diff --git a/docs/pedagogie/00-reprise-et-etat.md b/docs/pedagogie/00-reprise-et-etat.md index aaf8320..046beaa 100644 --- a/docs/pedagogie/00-reprise-et-etat.md +++ b/docs/pedagogie/00-reprise-et-etat.md @@ -1,6 +1,6 @@ # Reprise du projet — où nous en sommes -> Dernière mise à jour : projet Flutter créé dans `swiftyCompanion/swifty_companion/`. +> Dernière mise à jour : étape 4 codée (profil complet). ## En bref @@ -8,7 +8,8 @@ |---|---| | **Fait** | Structure dépôt perso, sujet FR, paquet rendu, **projet Flutter** (`swifty_companion/`) | | **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 @@ -27,7 +28,6 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso) │ └── 00-reprise-et-etat.md ← ce fichier └── swiftyCompanion/ ← futur dépôt ÉCOLE (racine Vogosphere) ├── README.md - ├── .gitignore ├── .env.example └── 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 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`). -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 @@ -45,25 +46,37 @@ swifty_companion/ ← dépôt Git de TRAVAIL (perso) |-------|---------|--------| | **0** | Structure, traduction sujet, séparation travail / école, README rendu | ✅ **Terminé** | | **1** | Projet Flutter `swiftyCompanion/swifty_companion/` | ✅ **Terminé** | -| **2** | OAuth2 (`IntraAuthService`, `/v2/me`) | ✅ **Validé** (`fgras-ca`) | -| **3** | Vue 1 : `SearchScreen` + `IntraUserService` + navigation profil | ✅ **Codé** — à tester sur téléphone | -| **4** | Vue 2 : profil complet (≥ 4 champs, skills, projets) | ⏳ **Prochaine** | -| **5** | Retour vue 1 + layout adaptatif | ⏳ | +| **2** | OAuth2 (`IntraAuthService`, `/v2/me`) | ✅ **Validé** | +| **3** | Vue 1 : `SearchScreen` + `IntraUserService` + navigation profil | ✅ **Codé** — tester sur téléphone | +| **4** | Vue 2 : `IntraProfileService` + profil (skills, projets, champs) | ✅ **Codé** — tester sur téléphone | +| **5** | Retour vue 1 + layout adaptatif (polish) | ⏳ **Prochaine** | | **6** | Passage checklist [swiftyCompanion/README.md](../../swiftyCompanion/README.md) | ⏳ | | **7** | Bonus : refresh token à expiration | ⏳ | +## 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) 1. Ouvrir le dépôt : `~/projets/42School/swifty_companion` 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). -4. Lancer l’app : +3. Vérifier Flutter : `flutter doctor` et `flutter devices`. +4. Lancer l’app (après modification `.env`, relancer `flutter run`) : ```bash cd swiftyCompanion/swifty_companion flutter run -d ``` -5. **Étape 2** — suivre [etape-02-oauth-intra.md](etape-02-oauth-intra.md) (app OAuth Intra, `.env`, service token unique). -6. Dans le chat : demander l’**implémentation pas à pas** du code OAuth quand l’app OAuth Intra est créée. +5. Tester le parcours : OAuth → recherche → profil → retour → erreurs réseau. ## 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) | | 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) | | 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 | -|---------------|--------| -| `etape-01-flutter-init.md` | Après validation étape 1 | -| `etape-02-oauth.md` | Début OAuth Intra | -| `etape-03-vue-recherche.md` | Vue 1 | -| `etape-04-vue-profil.md` | Vue 2 + API | -| `etape-05-navigation-layout.md` | Navigation + responsive | -| `etape-06-bonus-token.md` | Bonus | +| Fichier | Statut | +|---------|--------| +| [etape-02-oauth-intra.md](etape-02-oauth-intra.md) | ✅ | +| [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | ✅ | +| [etape-04-vue-profil.md](etape-04-vue-profil.md) | ✅ | +| `etape-05-navigation-layout.md` | 🔜 | +| `etape-06-bonus-token.md` | 🔜 | ## 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). -- Aucun `git init` obligatoire dans `swiftyCompanion/` tant que tu n’es pas prêt à lier Vogosphere. diff --git a/docs/pedagogie/README.md b/docs/pedagogie/README.md index 4898d8a..778f8e9 100644 --- a/docs/pedagogie/README.md +++ b/docs/pedagogie/README.md @@ -4,7 +4,7 @@ Parcours pour construire le projet **étape par étape**. ## Reprise rapide -→ **[00-reprise-et-etat.md](00-reprise-et-etat.md)** — état actuel, arborescence, prochaine action (**étape 1 : `flutter create`**). +→ **[00-reprise-et-etat.md](00-reprise-et-etat.md)** — état actuel, arborescence, prochaine action (**étape 5 : layout / tests**). ## Prérequis @@ -17,13 +17,12 @@ Base via la [piscine mobile](../../../piscine_mobile/README.md) sur le même dis | É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** | -| **2** | OAuth2 Intra : token + `/v2/me` | ✅ Fait | -| **3** | **Vue 1** : recherche par login + erreurs | ✅ Codé → tester · [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 + image), skills, projets (validés + failed) | ⏳ | -| **5** | Navigation retour + layout responsive | ⏳ | +| **2** | OAuth2 Intra : token + `/v2/me` | ✅ **Fait** — [etape-02-oauth-intra.md](etape-02-oauth-intra.md) | +| **3** | **Vue 1** : recherche par login + erreurs | ✅ **Codé** — [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | +| **4** | **Vue 2** : profil (≥ 4 champs, skills, projets failed) | ✅ **Codé** — [etape-04-vue-profil.md](etape-04-vue-profil.md) | +| **5** | Navigation retour + layout responsive (polish) | ⏳ **Prochaine** | | **6** | Tests manuels + checklist peer-eval | ⏳ | | **7** | **Bonus** : refresh token à l’expiration | ⏳ | @@ -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 | | [etape-02-oauth-intra.md](etape-02-oauth-intra.md) | ✅ Guide étape 2 | | [etape-03-vue-recherche.md](etape-03-vue-recherche.md) | ✅ Guide étape 3 | -| [etape-04-vue-profil.md](etape-04-vue-profil.md) | 🔜 après étape 3 | -| `etape-04-vue-profil.md` | 🔜 | +| [etape-04-vue-profil.md](etape-04-vue-profil.md) | ✅ Guide étape 4 | | `etape-05-navigation-layout.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. diff --git a/docs/pedagogie/etape-02-oauth-intra.md b/docs/pedagogie/etape-02-oauth-intra.md index 2941700..7a10e4d 100644 --- a/docs/pedagogie/etape-02-oauth-intra.md +++ b/docs/pedagogie/etape-02-oauth-intra.md @@ -85,7 +85,7 @@ Endpoints 42 (référence) : | Token | `POST https://api.intra.42.fr/oauth/token` | | API | `GET https://api.intra.42.fr/v2/...` avec header `Authorization: Bearer ` | -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)). --- diff --git a/docs/pedagogie/etape-04-vue-profil.md b/docs/pedagogie/etape-04-vue-profil.md index 734ba61..5ddf638 100644 --- a/docs/pedagogie/etape-04-vue-profil.md +++ b/docs/pedagogie/etape-04-vue-profil.md @@ -1,21 +1,113 @@ # É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 | -|----------|--------| -| ≥ 4 infos | login, email, mobile, level, location, wallet, evaluations, … | -| Photo | Image de profil (`image_url` / `image.link` selon API) | -| Skills | Niveau + pourcentage | -| Projets | Terminés **y compris failed** | -| Retour | Bouton retour vers vue 1 | -| Layout | Responsive (étape 5 peut affiner) | +| Exigence | Cette étape | +|----------|-------------| +| ≥ 4 infos + photo | login (titre / avatar), email, téléphone, niveau cursus, localisation, wallet | +| Skills | Niveau entier + **pourcentage** (partie décimale du `level` API) | +| Projets | Statuts **`finished`** et **`failed`** | +| Erreurs | Réseau, 401, réponse API inattendue — message + réessai | +| Token | Un seul OAuth : `IntraAuthService` pour tous les `GET` | +| 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.). -- Token : toujours via `IntraAuthService`. +## Parcours utilisateur (vue 2) -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 +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 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). diff --git a/docs/pedagogie/intra-oauth-formulaire.md b/docs/pedagogie/intra-oauth-formulaire.md index 2280b12..00895d5 100644 --- a/docs/pedagogie/intra-oauth-formulaire.md +++ b/docs/pedagogie/intra-oauth-formulaire.md @@ -37,11 +37,14 @@ http://localhost:8080/callback ## Champs d’application (scopes) -Cocher **uniquement** : +Cocher : - [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 diff --git a/swiftyCompanion/README.md b/swiftyCompanion/README.md index 57e57b3..46f93c3 100644 --- a/swiftyCompanion/README.md +++ b/swiftyCompanion/README.md @@ -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. -**É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 @@ -17,7 +17,7 @@ flutter pub get flutter run -d ``` -É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 : diff --git a/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart b/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart index 9688fe2..65efb93 100644 --- a/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart +++ b/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart @@ -1,12 +1,61 @@ import 'package:flutter/material.dart'; -/// Vue 2 — squelette étape 3 ; détails complets à l’étape 4. -class ProfileScreen extends StatelessWidget { +import '../services/intra_api_exception.dart'; +import '../services/intra_profile_service.dart'; + +/// Vue 2 — profil complet (étape 4). +class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key, required this.user}); + /// Résultat minimal de la recherche (au moins `id` + `login`). final Map user; - String? _imageUrl() { + @override + State createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends State { + final _profiles = IntraProfileService.instance; + + bool _loading = true; + String? _error; + IntraUserProfile? _profile; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 user) { final image = user['image']; if (image is Map) { final link = image['link']; @@ -17,10 +66,17 @@ class ProfileScreen extends StatelessWidget { return null; } + String _display(dynamic value, {String fallback = '—'}) { + if (value == null) return fallback; + final text = value.toString().trim(); + return text.isEmpty ? fallback : text; + } + @override Widget build(BuildContext context) { - final login = user['login']?.toString() ?? '?'; - final imageUrl = _imageUrl(); + final user = _profile?.user ?? widget.user; + final login = _display(user['login'], fallback: '?'); + final imageUrl = _imageUrl(user); return Scaffold( appBar: AppBar( @@ -28,39 +84,175 @@ class ProfileScreen extends StatelessWidget { backgroundColor: const Color(0xFF00BABC), foregroundColor: Colors.black, ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24), + 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), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + 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, + backgroundImage: + imageUrl != null ? NetworkImage(imageUrl!) : null, + child: imageUrl == null + ? Text( + login.isNotEmpty ? login[0].toUpperCase() : '?', + style: const TextStyle(fontSize: 32), + ) + : null, + ), + const SizedBox(height: 12), + Text( + login, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ); + } +} + +class _DetailsCard extends StatelessWidget { + const _DetailsCard({ + required this.user, + required this.cursusLevel, + required this.display, + }); + + final Map 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.stretch, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: CircleAvatar( - radius: 56, - backgroundImage: - imageUrl != null ? NetworkImage(imageUrl) : null, - child: imageUrl == null - ? Text( - login.isNotEmpty ? login[0].toUpperCase() : '?', - style: const TextStyle(fontSize: 32), - ) - : null, - ), - ), - const SizedBox(height: 16), Text( - login, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall, + 'Informations', + style: Theme.of(context).textTheme.titleMedium, ), - const SizedBox(height: 24), - Text( - 'Étape 4 à venir : email, level, skills, projets (dont failed), etc.', - style: Theme.of(context).textTheme.bodyMedium, + const SizedBox(height: 12), + _DetailRow(label: 'Email', value: display(user['email'])), + _DetailRow(label: 'Téléphone', value: display(user['phone'])), + _DetailRow(label: 'Niveau cursus', value: levelText), + _DetailRow( + label: 'Localisation', + value: display(user['location']), ), - const SizedBox(height: 8), - Text( - 'ID Intra : ${user['id'] ?? '—'}', - style: Theme.of(context).textTheme.bodySmall, + _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 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 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, + ), + ), + ); + }), + ], + ), + ), + ); + } +} diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_api_exception.dart b/swiftyCompanion/swifty_companion/lib/services/intra_api_exception.dart index 37836d9..86d5994 100644 --- a/swiftyCompanion/swifty_companion/lib/services/intra_api_exception.dart +++ b/swiftyCompanion/swifty_companion/lib/services/intra_api_exception.dart @@ -30,3 +30,18 @@ class IntraUnauthorizedException extends IntraApiException { class IntraApiErrorException extends IntraApiException { 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).', + ); +} diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart b/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart index 1d49579..aaa7a4f 100644 --- a/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart +++ b/swiftyCompanion/swifty_companion/lib/services/intra_auth_service.dart @@ -36,7 +36,8 @@ class IntraAuthService { IntraConfig.redirectUri, clientSecret: IntraConfig.clientSecret, serviceConfiguration: _serviceConfiguration, - scopes: ['public'], + // `projects` requis pour projects_users ; skills via cursus_users (pas /experiences). + scopes: ['public', 'projects'], ), ); diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_http.dart b/swiftyCompanion/swifty_companion/lib/services/intra_http.dart new file mode 100644 index 0000000..7813e84 --- /dev/null +++ b/swiftyCompanion/swifty_companion/lib/services/intra_http.dart @@ -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 intraApiGet( + Uri uri, { + required Map 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.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); +} diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart b/swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart new file mode 100644 index 0000000..24150de --- /dev/null +++ b/swiftyCompanion/swifty_companion/lib/services/intra_profile_service.dart @@ -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 user; + final double? cursusLevel; + final List skills; + final List 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 _cache = {}; + + /// Charge profil complet (user, cursus, skills, projets) avec le token existant. + Future fetchProfile(Map 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) { + throw IntraApiErrorException('Réponse API inattendue ($userPath)'); + } + + final embeddedCursus = userDecoded['cursus_users']; + final List cursusList; + if (embeddedCursus is List && embeddedCursus.isNotEmpty) { + cursusList = embeddedCursus; + } else { + await Future.delayed(_betweenRequests); + cursusList = await _getJsonList('/users/$id/cursus_users'); + } + + await Future.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 _getJson(String path, {Map? query}) async { + final uri = Uri.parse('${IntraConfig.apiBaseUrl}$path').replace( + queryParameters: query, + ); + final response = await intraApiGet( + uri, + headers: _auth.authHeaders(), + ); + return decodeIntraBody(response); + } + + Future> _getJsonList( + String path, { + Map? query, + }) async { + final decoded = await _getJson(path, query: query); + if (decoded is! List) { + throw IntraApiErrorException('Réponse API inattendue ($path)'); + } + return decoded; + } + + Map? _pickPrimaryCursus(List cursusUsers) { + Map? fallback; + for (final entry in cursusUsers) { + if (entry is! Map) 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? cursusUser) { + if (cursusUser == null) return null; + final level = cursusUser['level']; + return level is num ? level.toDouble() : null; + } + + List _parseSkillsFromCursus( + Map? primaryCursus, + List allCursus, + ) { + final sources = >[]; + if (primaryCursus != null) { + sources.add(primaryCursus); + } else { + for (final c in allCursus) { + if (c is Map) sources.add(c); + } + } + + final byId = {}; + for (final cursusUser in sources) { + final skills = cursusUser['skills']; + if (skills is! List) continue; + for (final skill in skills) { + if (skill is! Map) 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 _parseProjects(List projectsUsers) { + final projects = []; + + for (final entry in projectsUsers) { + if (entry is! Map) 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; + } +} diff --git a/swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart b/swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart index 0ccc960..db51aa4 100644 --- a/swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart +++ b/swiftyCompanion/swifty_companion/lib/services/intra_user_service.dart @@ -6,6 +6,7 @@ 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'; class IntraUserService { IntraUserService._(); @@ -28,7 +29,10 @@ class IntraUserService { ); try { - final response = await http.get(uri, headers: _auth.authHeaders()); + final response = await intraApiGet( + uri, + headers: _auth.authHeaders(), + ); return _parseUserListResponse(response, trimmed); } on IntraApiException { rethrow; @@ -43,14 +47,7 @@ class IntraUserService { http.Response response, String login, ) { - if (response.statusCode == 401) { - throw const IntraUnauthorizedException(); - } - if (response.statusCode != 200) { - throw IntraApiErrorException( - 'Erreur API (${response.statusCode})', - ); - } + ensureIntraOk(response); final decoded = jsonDecode(response.body); if (decoded is! List) {