This commit is contained in:
2026-09-29 14:26:17 +02:00
parent 88a7c2eba0
commit 06fe43bba3
24 changed files with 985 additions and 174 deletions

View File

@@ -7,8 +7,8 @@
| | |
|---|---|
| **Fait** | Structure dépôt perso, sujet FR, paquet rendu, **projet Flutter** (`swifty_companion/`) |
| **Pas commencé** | OAuth Intra, les 2 vues, appels API 42 |
| **Prochaine action** | **Étape 2** — [OAuth2 Intra + token unique](etape-02-oauth-intra.md) |
| **Fait** | OAuth + token + `/v2/me` (login connecté) |
| **Prochaine action** | **Étape 4** — [vue profil complète](etape-04-vue-profil.md) |
## Arborescence actuelle
@@ -45,9 +45,9 @@ 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** | App OAuth sur l’Intra + flux OAuth2 + token unique réutilisé | ⏳ **En cours — prochaine** |
| **3** | Vue 1 : recherche login + erreurs (introuvable, réseau) | ⏳ |
| **4** | Vue 2 : profil (≥ 4 champs + photo), skills, projets (dont failed) | ⏳ |
| **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 | ⏳ |
| **6** | Passage checklist [swiftyCompanion/README.md](../../swiftyCompanion/README.md) | ⏳ |
| **7** | Bonus : refresh token à expiration | ⏳ |

View File

@@ -19,8 +19,9 @@ Base via la [piscine mobile](../../../piscine_mobile/README.md) sur le même dis
|-------|----------|--------|
| **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) |
| **1** | Projet `swiftyCompanion/swifty_companion/` + `flutter run` | ✅ **Fait** |
| **2** | OAuth2 Intra : login, stockage token, **un token pour toutes les requêtes** | ⏳ **Prochaine** → [etape-02-oauth-intra.md](etape-02-oauth-intra.md) |
| **3** | **Vue 1** : recherche par login + erreurs (404, réseau) | ⏳ → [etape-03-vue-recherche.md](etape-03-vue-recherche.md) |
| **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 | ⏳ |
| **6** | Tests manuels + checklist peer-eval | ⏳ |
@@ -38,7 +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) | 🔜 après OAuth |
| [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-05-navigation-layout.md` | 🔜 |
| `etape-06-bonus-token.md` | 🔜 |

View File

@@ -1,12 +1,107 @@
# Étape 3 — Vue 1 (recherche par login)
# Étape 3 — Vue 1 : recherche par login
> **Prérequis :** [étape 2 — OAuth Intra](etape-02-oauth-intra.md) terminée (token disponible).
> **Prérequis :** étape 2 OK — OAuth actif, token unique (`IntraAuthService`).
## Objectif
## Objectif sujet
- **Première vue** : saisie d’un **login** 42 + bouton rechercher.
- Appel API avec le **token OAuth** déjà obtenu.
- Gestion des erreurs : **utilisateur introuvable**, **réseau**, token invalide / non connecté.
- Navigation vers la **vue 2** si le login existe.
| Exigence | Cette étape |
|----------|-------------|
| Au moins 2 vues | **Vue 1** : recherche · **Vue 2** : profil (étape 4) |
| Erreurs gérées | Login introuvable, réseau, non connecté |
| Token unique | Réutiliser `IntraAuthService.instance` (ne pas refaire OAuth) |
Guide détaillé à compléter quand l’étape 2 est OK — demande l’implémentation pas à pas dans le chat.
---
## Parcours utilisateur (vue 1)
1. L’utilisateur est déjà connecté à l’API (étape 2) — ou bouton « Se connecter » si token absent.
2. **Champ texte** + bouton **Rechercher** (login 42, ex. `jdoe`).
3. Appel API avec `Authorization: Bearer <token>`.
4. **Succès** → navigation vers **vue 2** (profil).
5. **Échec** → message clair, pas de crash.
---
## API 42 utile
Doc : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
| Action | Requête typique |
|--------|-----------------|
| Utilisateur par login | `GET /v2/users/{login}` ou `GET /v2/users?filter[login]=<login>` |
| Toi (déjà fait) | `GET /v2/me` |
Vérifier dans l’apidoc la forme exacte supportée ; beaucoup de projets utilisent :
```http
GET https://api.intra.42.fr/v2/users?filter[login]=jdoe
Authorization: Bearer <access_token>
```
Réponse : tableau JSON ; **vide** = login inconnu.
---
## Fichiers à ajouter (étape 3)
```
lib/
├── services/
│ ├── intra_auth_service.dart # existant
│ └── intra_user_service.dart # GET user(s) avec token
├── screens/
│ ├── auth_screen.dart # option : fusionner ou garder login au démarrage
│ ├── search_screen.dart # VUE 1
│ └── profile_screen.dart # VUE 2 (squelette → étape 4)
└── main.dart # home → SearchScreen si token OK
```
---
## `IntraUserService` (idée)
- Méthode `Future<Map<String, dynamic>> fetchUserByLogin(String login)`.
- Utilise `IntraAuthService.instance.authHeaders()`.
- **404 / liste vide** → exception métier `UserNotFoundException`.
- **Socket / timeout** → `NetworkException`.
- **401** → token expiré → message « reconnecte-toi ».
---
## Gestion des erreurs (sujet)
| Situation | Message utilisateur (exemple) |
|-----------|-------------------------------|
| Login inconnu | `Aucun utilisateur pour ce login` |
| Pas de réseau | `Connexion impossible. Vérifie le réseau.` |
| Non connecté OAuth | `Connecte-toi d’abord à l’API 42` |
| Champ vide | `Saisis un login` |
---
## Navigation
```text
SearchScreen --[login trouvé]--> ProfileScreen(login)
ProfileScreen --[retour]--> SearchScreen
```
`Navigator.push` / `pop` ou `go_router` — rester simple (Navigator 1.0 suffit).
---
## Critères « étape 3 terminée »
| # | Test |
|---|------|
| 1 | Login valide (ex. `fgras-ca`) → ouvre vue 2 |
| 2 | Login `zzzzzzzz` → message erreur |
| 3 | Mode avion → message réseau |
| 4 | Retour depuis vue 2 → nouvelle recherche possible |
| 5 | Un seul OAuth ; les recherches réutilisent le même token |
---
## Ensuite — étape 4
→ [etape-04-vue-profil.md](etape-04-vue-profil.md) : photo, ≥ 4 champs, skills, projets (dont failed), layout adaptatif.

View File

@@ -0,0 +1,21 @@
# Étape 4 — Vue 2 : profil complet
> **Prérequis :** [étape 3 — recherche](etape-03-vue-recherche.md) (navigation vers vue 2 avec login valide).
## Objectif sujet (partie obligatoire)
| 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) |
## API
- `GET /v2/users/{id}` avec champs / includes selon apidoc (`cursus_users`, `projects_users`, `skills`, etc.).
- 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.

View File

@@ -15,22 +15,20 @@
## URI de redirection
**Une seule ligne**, sans guillemets :
**Téléphone physique (recommandé Swifty)** — une ligne :
```text
com.example.swiftycompanion://callback
```
(Sans `_` dans le schéma — souvent accepté par l’Intra ; sinon voir [oauth-test-manuel.md](oauth-test-manuel.md).)
**Émulateur seulement** (optionnel) :
```text
http://localhost:8080/callback
```
Si le formulaire affiche encore *invalid format*, tester **une** de ces lignes (toujours une seule) :
```text
http://localhost:3000/callback
```
```text
http://127.0.0.1:8080/callback
```
**À ne pas mettre :**
- `com.example.swifty_companion://callback`

View File

@@ -0,0 +1,78 @@
# OAuth 42 — tests manuels des URLs
## Pourquoi pas `http://localhost` sur téléphone ?
| Environnement | `http://localhost:8080/callback` |
|---------------|----------------------------------|
| Émulateur Android | Parfois OK (localhost = l’émulateur) |
| **Téléphone physique** | ❌ « Site inaccessible » — localhost = le téléphone, pas ton PC |
**Swifty Companion (mobile réel)** : utiliser un **schéma d’app** :
```text
com.example.swiftycompanion://callback
```
À enregistrer sur l’Intra **et** dans `.env` (`REDIRECT_URI`).
---
## Endpoints 42 (référence sujet)
| Rôle | URL |
|------|-----|
| Autorisation | `https://api.intra.42.fr/oauth/authorize` |
| Token | `https://api.intra.42.fr/oauth/token` |
| API (ex. moi) | `https://api.intra.42.fr/v2/me` |
Doc : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
---
## 1. Tester l’API (sans app)
Dans un navigateur (connecté à l’Intra ou non pour cette étape) :
```text
https://api.intra.42.fr/
```
Réponse JSON attendue (message API) → l’API est joignable.
---
## 2. Tester la page d’autorisation (navigateur)
Remplace `TON_CLIENT_ID` par l’**UID** de ton app OAuth.
**Redirect actuel (mobile)** :
```text
https://api.intra.42.fr/oauth/authorize?client_id=TON_CLIENT_ID&redirect_uri=com.example.swiftycompanion%3A%2F%2Fcallback&response_type=code&scope=public
```
| Résultat | Signification |
|----------|----------------|
| Page login / autorisation 42 | ✅ URL d’auth OK |
| Erreur `redirect_uri` | ❌ URI pas enregistrée sur l’Intra ou différente de `.env` |
| Erreur `invalid_client` | ❌ `client_id` incorrect |
Après validation, le navigateur tente d’ouvrir `com.example.swiftycompanion://callback?code=...` → sur PC ça échoue (normal) ; **sur le téléphone avec l’app installée**, l’app doit s’ouvrir.
---
## 3. Alignement obligatoire (3 endroits identiques)
1. [Application OAuth Intra](https://profile.intra.42.fr/oauth/applications) → **URI de redirection**
2. `swifty_companion/.env` → `REDIRECT_URI=...`
3. Code Flutter → même valeur via `IntraConfig.redirectUri`
---
## 4. Ancien redirect (à retirer sur l’Intra si présent)
```text
http://localhost:8080/callback
```
Remplacer par `com.example.swiftycompanion://callback` (une ligne dans le formulaire).

View File

@@ -4,4 +4,4 @@
CLIENT_ID=
CLIENT_SECRET=
# Identique au champ « URI de redirection » sur l’Intra (une seule ligne, sans guillemets)
REDIRECT_URI=http://localhost:8080/callback
REDIRECT_URI=com.example.swiftycompanion://callback

View File

@@ -10,10 +10,15 @@ Application mobile — profil étudiant 42 via l’**API Intra** (OAuth2).
```bash
cd swifty_companion
# Seul ce fichier compte pour Flutter (asset .env) :
# swifty_companion/.env
# Optionnel : copie de sauvegarde dans swiftyCompanion/.env — garder les deux identiques.
flutter pub get
flutter run -d <device>
```
Écran actuel : **connexion OAuth 42** puis affichage du login (`/v2/me`).
Vérifications :
```bash

View File

@@ -0,0 +1,7 @@
# Renommer / copier en .env — ne jamais committer .env
# Application OAuth : https://profile.intra.42.fr/oauth/applications
CLIENT_ID=u-s4t2ud-27c021a8466dee9257a38cb90b4a9dced60289e9ac5426724b4b18efc2f7ff8b
CLIENT_SECRET=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
# Identique au champ « URI de redirection » sur l’Intra (une seule ligne, sans guillemets)
REDIRECT_URI=com.example.swiftycompanion://callback

View File

@@ -6,7 +6,7 @@ plugins {
android {
namespace = "com.example.swifty_companion"
compileSdk = flutter.compileSdkVersion
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
@@ -27,6 +27,9 @@ android {
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
// Schéma OAuth mobile (REDIRECT_URI = com.example.swiftycompanion://callback)
manifestPlaceholders["appAuthRedirectScheme"] = "com.example.swiftycompanion"
}
buildTypes {

View File

@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="swifty_companion"
android:name="${applicationName}"
@@ -7,15 +9,10 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
@@ -25,21 +22,35 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
tools:node="replace">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="com.example.swiftycompanion"
android:host="callback"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
</manifest>

View File

@@ -19,6 +19,15 @@ subprojects {
project.evaluationDependsOn(":app")
}
// flutter_appauth + AndroidX : compileSdk ≥ 34
subprojects {
pluginManager.withPlugin("com.android.library") {
extensions.configure<com.android.build.gradle.LibraryExtension>("android") {
compileSdk = 36
}
}
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// Endpoints et variables OAuth 42 (fichier `.env` à la racine du package Flutter).
abstract final class IntraConfig {
static const String authorizeUrl = 'https://api.intra.42.fr/oauth/authorize';
static const String tokenUrl = 'https://api.intra.42.fr/oauth/token';
static const String apiBaseUrl = 'https://api.intra.42.fr/v2';
static String get clientId => dotenv.env['CLIENT_ID']?.trim() ?? '';
static String get clientSecret => dotenv.env['CLIENT_SECRET']?.trim() ?? '';
static String get redirectUri => dotenv.env['REDIRECT_URI']?.trim() ?? '';
static bool get isConfigured =>
clientId.isNotEmpty && clientSecret.isNotEmpty && redirectUri.isNotEmpty;
/// Aide debug (écran auth) — ne pas afficher le secret complet.
static String get clientIdSuffix {
final id = clientId;
if (id.length < 6) return '?';
return id.substring(id.length - 6);
}
}

View File

@@ -1,121 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
void main() {
runApp(const MyApp());
import 'screens/search_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: '.env');
runApp(const SwiftyCompanionApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
class SwiftyCompanionApp extends StatelessWidget {
const SwiftyCompanionApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
title: 'Swifty Companion',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF00BABC)),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
home: const SearchScreen(),
onUnknownRoute: (_) => MaterialPageRoute<void>(
builder: (_) => const SearchScreen(),
),
);
}

View File

@@ -0,0 +1,139 @@
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

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
/// Vue 2 — squelette étape 3 ; détails complets à l’étape 4.
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key, required this.user});
final Map<String, dynamic> user;
String? _imageUrl() {
final image = user['image'];
if (image is Map<String, dynamic>) {
final link = image['link'];
if (link is String && link.isNotEmpty) return link;
}
final url = user['image_url'];
if (url is String && url.isNotEmpty) return url;
return null;
}
@override
Widget build(BuildContext context) {
final login = user['login']?.toString() ?? '?';
final imageUrl = _imageUrl();
return Scaffold(
appBar: AppBar(
title: Text(login),
backgroundColor: const Color(0xFF00BABC),
foregroundColor: Colors.black,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
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,
),
const SizedBox(height: 24),
Text(
'Étape 4 à venir : email, level, skills, projets (dont failed), etc.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'ID Intra : ${user['id'] ?? '—'}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,208 @@
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_api_exception.dart';
import '../services/intra_auth_service.dart';
import '../services/intra_user_service.dart';
import 'profile_screen.dart';
/// Vue 1 — recherche par login 42.
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _auth = IntraAuthService.instance;
final _users = IntraUserService.instance;
final _loginController = TextEditingController();
bool _connecting = false;
bool _searching = false;
String? _error;
String? _sessionLogin;
@override
void dispose() {
_loginController.dispose();
super.dispose();
}
Future<void> _connect() async {
setState(() {
_connecting = true;
_error = null;
});
try {
await _auth.signIn();
final me = await _auth.fetchMe();
if (!mounted) return;
setState(() {
_sessionLogin = me['login'] as String?;
});
} 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';
});
} 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(() => _connecting = false);
}
}
void _disconnect() {
_auth.signOut();
setState(() {
_sessionLogin = null;
_error = null;
});
}
Future<void> _search() async {
setState(() {
_searching = true;
_error = null;
});
try {
final user = await _users.fetchUserByLogin(_loginController.text);
if (!mounted) return;
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ProfileScreen(user: user),
),
);
} on IntraApiException catch (e) {
if (!mounted) return;
setState(() => _error = e.message);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _searching = false);
}
}
@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 dans swifty_companion/.env',
),
),
);
}
final isAuthenticated = _auth.isAuthenticated;
return Scaffold(
appBar: AppBar(
title: const Text('Swifty Companion'),
backgroundColor: const Color(0xFF00BABC),
foregroundColor: Colors.black,
actions: [
if (isAuthenticated)
IconButton(
onPressed: _connecting || _searching ? null : _disconnect,
icon: const Icon(Icons.logout),
tooltip: 'Déconnexion API',
),
],
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isAuthenticated) ...[
const Text(
'Connexion API 42',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Connecte-toi une fois ; le token servira pour toutes les recherches.',
),
const SizedBox(height: 16),
FilledButton(
onPressed: _connecting ? null : _connect,
child: _connecting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Se connecter avec 42'),
),
] else ...[
Text(
'Session : ${_sessionLogin ?? 'connecté'}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 24),
const Text(
'Rechercher un étudiant',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
TextField(
controller: _loginController,
decoration: const InputDecoration(
labelText: 'Login 42',
hintText: 'ex. fgras-ca',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.search,
onSubmitted: (_) {
if (!_searching) _search();
},
enabled: !_searching,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _searching ? null : _search,
child: _searching
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Rechercher'),
),
],
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,32 @@
/// Erreurs métier pour les appels API 42.
sealed class IntraApiException implements Exception {
const IntraApiException(this.message);
final String message;
@override
String toString() => message;
}
class IntraNotAuthenticatedException extends IntraApiException {
const IntraNotAuthenticatedException()
: super('Connecte-toi d’abord à l’API 42');
}
class IntraUserNotFoundException extends IntraApiException {
const IntraUserNotFoundException()
: super('Aucun utilisateur pour ce login');
}
class IntraNetworkException extends IntraApiException {
const IntraNetworkException()
: super('Connexion impossible. Vérifie le réseau.');
}
class IntraUnauthorizedException extends IntraApiException {
const IntraUnauthorizedException()
: super('Session expirée. Reconnecte-toi à l’API 42');
}
class IntraApiErrorException extends IntraApiException {
const IntraApiErrorException(super.message);
}

View File

@@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:http/http.dart' as http;
import '../config/intra_config.dart';
/// OAuth2 Intra : un token réutilisé pour toutes les requêtes API.
class IntraAuthService {
IntraAuthService._();
static final IntraAuthService instance = IntraAuthService._();
final FlutterAppAuth _appAuth = const FlutterAppAuth();
String? _accessToken;
String? get accessToken => _accessToken;
bool get isAuthenticated => _accessToken != null && _accessToken!.isNotEmpty;
static final AuthorizationServiceConfiguration _serviceConfiguration =
AuthorizationServiceConfiguration(
authorizationEndpoint: IntraConfig.authorizeUrl,
tokenEndpoint: IntraConfig.tokenUrl,
);
Future<void> signIn() async {
if (!IntraConfig.isConfigured) {
throw StateError(
'CLIENT_ID, CLIENT_SECRET ou REDIRECT_URI manquant dans .env',
);
}
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
IntraConfig.clientId,
IntraConfig.redirectUri,
clientSecret: IntraConfig.clientSecret,
serviceConfiguration: _serviceConfiguration,
scopes: ['public'],
),
);
_accessToken = result.accessToken;
if (!isAuthenticated) {
throw StateError('Connexion 42 annulée ou token absent');
}
}
void signOut() {
_accessToken = null;
}
/// Header Authorization pour les appels `api.intra.42.fr`.
Map<String, String> authHeaders() {
if (!isAuthenticated) {
throw StateError('Non connecté à l’Intra');
}
return {
'Authorization': 'Bearer $_accessToken',
'Accept': 'application/json',
};
}
Future<Map<String, dynamic>> fetchMe() async {
final response = await http.get(
Uri.parse('${IntraConfig.apiBaseUrl}/me'),
headers: authHeaders(),
);
if (response.statusCode != 200) {
throw Exception('GET /v2/me a échoué (${response.statusCode})');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
}

View File

@@ -0,0 +1,69 @@
import 'dart:convert';
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';
class IntraUserService {
IntraUserService._();
static final IntraUserService instance = IntraUserService._();
final IntraAuthService _auth = IntraAuthService.instance;
/// Recherche par login — réutilise le token OAuth (pas de nouvel OAuth).
Future<Map<String, dynamic>> fetchUserByLogin(String login) async {
final trimmed = login.trim();
if (trimmed.isEmpty) {
throw const IntraApiErrorException('Saisis un login');
}
if (!_auth.isAuthenticated) {
throw const IntraNotAuthenticatedException();
}
final uri = Uri.parse('${IntraConfig.apiBaseUrl}/users').replace(
queryParameters: {'filter[login]': trimmed},
);
try {
final response = await http.get(uri, headers: _auth.authHeaders());
return _parseUserListResponse(response, trimmed);
} on IntraApiException {
rethrow;
} on SocketException {
throw const IntraNetworkException();
} on http.ClientException {
throw const IntraNetworkException();
}
}
Map<String, dynamic> _parseUserListResponse(
http.Response response,
String login,
) {
if (response.statusCode == 401) {
throw const IntraUnauthorizedException();
}
if (response.statusCode != 200) {
throw IntraApiErrorException(
'Erreur API (${response.statusCode})',
);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) {
throw IntraApiErrorException('Réponse API inattendue');
}
if (decoded.isEmpty) {
throw const IntraUserNotFoundException();
}
final user = decoded.first;
if (user is! Map<String, dynamic>) {
throw IntraApiErrorException('Réponse API inattendue');
}
return user;
}
}

View File

@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import flutter_appauth
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
}

View File

@@ -62,6 +62,30 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_appauth:
dependency: "direct main"
description:
name: flutter_appauth
sha256: "9bacc200937c61c7ed4e64ecee3fc0d4042133405a00b56de8fbe401aa16d228"
url: "https://pub.dev"
source: hosted
version: "12.1.0"
flutter_appauth_platform_interface:
dependency: transitive
description:
name: flutter_appauth_platform_interface
sha256: c88f8edf43bf3b2c8fed73a04ba78309f00c549306a27f5ef818d767701a6eb6
url: "https://pub.dev"
source: hosted
version: "12.1.0"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b
url: "https://pub.dev"
source: hosted
version: "5.2.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -75,6 +99,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
@@ -139,6 +179,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
@@ -192,6 +240,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
@@ -208,6 +264,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.13.3 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.38.1"

View File

@@ -34,6 +34,9 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_dotenv: ^5.2.1
flutter_appauth: ^12.0.0
http: ^1.2.2
dev_dependencies:
flutter_test:
@@ -57,9 +60,8 @@ flutter:
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
assets:
- .env
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see

View File

@@ -1,30 +1,20 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swifty_companion/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
testWidgets('App démarre avec titre Swifty Companion', (tester) async {
dotenv.testLoad(fileInput: '''
CLIENT_ID=test
CLIENT_SECRET=test
REDIRECT_URI=http://localhost:8080/callback
''');
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pumpWidget(const SwiftyCompanionApp());
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.text('Swifty Companion'), findsOneWidget);
expect(find.text('Se connecter avec 42'), findsOneWidget);
});
}