guidesoutenance

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

View File

@@ -2,37 +2,45 @@
Application mobile — profil étudiant 42 via l’**API Intra** (OAuth2).
> **Dépôt de rendu école** : ce dossier est conçu pour être la **racine** du dépôt Git déposé sur l’Intra. Il ne contient pas la documentation de travail (sujet traduit, guides) du dépôt perso.
> **Dépôt de rendu école** : ce dossier est la **racine** du dépôt Git Vogosphere. Pas de `docs/` ni de sujet traduit ici (dépôt perso séparé).
**État :** OAuth + vue recherche + vue profil (skills, projets). Prochaine étape : polish layout / tests device (dépôt de travail : `docs/pedagogie/00-reprise-et-etat.md`).
**État :** partie obligatoire + **bonus refresh token** (401 → refresh → retry API).
## Lancer le projet
```bash
cd swifty_companion
# Seul ce fichier compte pour Flutter (asset .env) :
# swifty_companion/.env
# Optionnel : copie de sauvegarde dans swiftyCompanion/.env — garder les deux identiques.
# Créer .env ici (voir .env.example à la racine du dépôt école)
flutter pub get
flutter run -d <device>
```
Écrans : **connexion OAuth 42** → **recherche par login** → **profil** (photo, champs, skills, projets validés / failed).
Le fichier **`.env`** doit être dans **`swifty_companion/.env`** (déclaré comme asset dans `pubspec.yaml`).
Vérifications :
```bash
flutter doctor
flutter devices
flutter analyze
flutter test
```
## Configuration (`.env`)
1. Créer une app OAuth : [profile.intra.42.fr/oauth/applications](https://profile.intra.42.fr/oauth/applications)
2. Copier `.env.example` → `.env` et renseigner `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI`
3. Ne **jamais** pousser `.env` sur le dépôt école (déjà ignoré par `.gitignore`)
1. App OAuth : [profile.intra.42.fr/oauth/applications](https://profile.intra.42.fr/oauth/applications)
2. Scopes : **`public`** + **`projects`** (projets sur le profil)
3. Redirect URI (ex. téléphone) : `com.example.swiftycompanion://callback` — identique sur l’Intra et dans `.env`
4. Ne **jamais** committer `.env`
Documentation API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
## Parcours app
1. **Se connecter avec 42** (token réutilisé pour toutes les requêtes)
2. **Rechercher** un login
3. **Profil** : photo, champs, skills (niveau + %), projets validés et failed
4. **Retour** → nouvelle recherche
## Partie obligatoire (rappel sujet v4)
@@ -41,28 +49,24 @@ Documentation API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
| 1 | Au moins **2 vues** |
| 2 | **Toutes** les erreurs gérées (login inconnu, réseau, …) |
| 3 | Vue 2 : infos du login s’il **existe** |
| 4 | **≥ 4 détails** (login, email, mobile, level, location, wallet, evaluations, …) + **photo de profil** |
| 4 | **≥ 4 détails** + **photo de profil** |
| 5 | **Skills** : niveau + pourcentage |
| 6 | **Projets** réalisés, **y compris les failed** |
| 7 | Retour possible vers la **vue 1** |
| 8 | Layout **adaptatif** (tailles / plateformes) |
| 9 | **OAuth2 Intra** — **un token réutilisé**, pas un token par requête |
| 8 | Layout **adaptatif** |
| 9 | **OAuth2** — **un token** réutilisé |
Contraintes : dernière API 42 disponible ; secrets uniquement en local (`.env`).
Certaines données peuvent ne pas être affichées (confidentialité / réglementation).
Certaines données peuvent être masquées par l’API (confidentialité).
## Bonus
- Rafraîchissement du token à expiration ; l’application reste fonctionnelle.
*(Bonus évalué seulement si la partie obligatoire est parfaite.)*
- Refresh token : `IntraAuthService.refreshAccessToken()` + retry dans `intraApiGet` sur 401.
## Soutenance — checklist peer-eval
| # | Test | Démo |
|---|------|------|
| 1 | `flutter test` | Tests OK (si présents) |
| 1 | `flutter test` | Tests OK |
| 2 | `flutter run -d <device>` | Vue 1 → login valide → vue 2 |
| 3 | Profil | Photo + ≥ 4 champs + skills + projets (dont failed) |
| 4 | Login invalide | Message clair, pas de crash |
@@ -71,13 +75,13 @@ Certaines données peuvent ne pas être affichées (confidentialité / réglemen
| 7 | Petit écran / rotation | UI lisible |
| 8 | Bonus | Refresh token si implémenté |
**Ordre suggéré :** login valide → skills / projets → login faux → hors ligne → retour recherche.
**Ordre suggéré :** connexion → profil → login faux → hors ligne → retour → `flutter test`.
## Fichiers de ce dépôt
| Fichier | Rôle |
|---------|------|
| `README.md` | Ce document (correcteurs / peer-eval) |
| `.gitignore` | Règles Git pour le rendu école |
| `.env.example` | Modèle de variables (sans secrets) |
| `lib/`, `android/`, … | Code Flutter (après `flutter create` ou développement) |
| `README.md` | Ce document |
| `.gitignore` | Exclut `.env`, build, etc. |
| `.env.example` | Modèle sans secrets |
| `swifty_companion/` | Projet Flutter (`lib/`, `android/`, …) |

View File

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

View File

@@ -1,139 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import '../config/intra_config.dart';
import '../services/intra_auth_service.dart';
/// Écran temporaire étape 2 : valider OAuth + token unique (/v2/me).
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _auth = IntraAuthService.instance;
bool _loading = false;
String? _error;
String? _login;
Future<void> _connect() async {
setState(() {
_loading = true;
_error = null;
_login = null;
});
try {
await _auth.signIn();
final me = await _auth.fetchMe();
if (!mounted) return;
setState(() {
_login = me['login'] as String?;
_error = null;
});
} on FlutterAppAuthUserCancelledException {
if (!mounted) return;
setState(() => _error = 'Connexion annulée');
} on FlutterAppAuthPlatformException catch (e) {
final d = e.platformErrorDetails;
if (!mounted) return;
setState(() {
_error = d.errorDescription ??
d.error ??
d.errorDebugDescription ??
e.message ??
'Échec OAuth (UID, SECRET, redirect Intra)';
});
} on PlatformException catch (e) {
if (!mounted) return;
setState(() => _error = e.message ?? 'Erreur OAuth');
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
void _disconnect() {
_auth.signOut();
setState(() => _login = null);
}
@override
Widget build(BuildContext context) {
if (!IntraConfig.isConfigured) {
return Scaffold(
appBar: AppBar(title: const Text('Swifty Companion')),
body: const Padding(
padding: EdgeInsets.all(24),
child: Text(
'Fichier .env manquant ou incomplet.\n'
'Copie swiftyCompanion/.env vers swifty_companion/.env '
'(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI).',
),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Swifty Companion'),
backgroundColor: const Color(0xFF00BABC),
foregroundColor: Colors.black,
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Étape 2 — Connexion API 42',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
_auth.isAuthenticated
? 'Token actif (réutilisé pour /v2/me)'
: 'Connecte-toi une fois ; le token servira pour toutes les requêtes.',
),
const SizedBox(height: 24),
if (_login != null) ...[
Text('Connecté : $_login', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 16),
OutlinedButton(onPressed: _disconnect, child: const Text('Déconnexion')),
] else
FilledButton(
onPressed: _loading ? null : _connect,
child: _loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Se connecter avec 42'),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const Spacer(),
Text(
'Config (.env dans swifty_companion/)\n'
'Redirect : ${IntraConfig.redirectUri}\n'
'CLIENT_ID …${IntraConfig.clientIdSuffix}\n'
'Après modification du .env : arrêter l’app puis flutter run (pas hot reload).',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
}

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:http/http.dart' as http;
import '../config/intra_config.dart';
import 'intra_http.dart';
import 'intra_oauth_tokens.dart';
/// OAuth2 Intra : un token réutilisé pour toutes les requêtes API.
/// OAuth2 Intra : access + refresh token, un jeton réutilisé pour les requêtes API.
class IntraAuthService {
IntraAuthService._();
static final IntraAuthService instance = IntraAuthService._();
@@ -13,9 +16,13 @@ class IntraAuthService {
final FlutterAppAuth _appAuth = const FlutterAppAuth();
String? _accessToken;
String? _refreshToken;
Future<bool>? _refreshInFlight;
String? get accessToken => _accessToken;
bool get isAuthenticated => _accessToken != null && _accessToken!.isNotEmpty;
bool get canRefresh =>
_refreshToken != null && _refreshToken!.isNotEmpty;
static final AuthorizationServiceConfiguration _serviceConfiguration =
AuthorizationServiceConfiguration(
@@ -36,20 +43,75 @@ class IntraAuthService {
IntraConfig.redirectUri,
clientSecret: IntraConfig.clientSecret,
serviceConfiguration: _serviceConfiguration,
// `projects` requis pour projects_users ; skills via cursus_users (pas /experiences).
scopes: ['public', 'projects'],
),
);
_accessToken = result.accessToken;
_applyOAuthResult(result.accessToken, result.refreshToken);
if (!isAuthenticated) {
throw StateError('Connexion 42 annulée ou token absent');
}
}
void _applyOAuthResult(String? access, String? refresh) {
_accessToken = access;
if (refresh != null && refresh.isNotEmpty) {
_refreshToken = refresh;
}
}
void signOut() {
_accessToken = null;
_refreshToken = null;
_refreshInFlight = null;
}
/// Rafraîchit l’access token (bonus). Retourne `false` si impossible.
Future<bool> refreshAccessToken() async {
if (!canRefresh) return false;
final inFlight = _refreshInFlight;
if (inFlight != null) return inFlight;
final future = _refreshAccessTokenOnce();
_refreshInFlight = future;
try {
return await future;
} finally {
if (identical(_refreshInFlight, future)) {
_refreshInFlight = null;
}
}
}
Future<bool> _refreshAccessTokenOnce() async {
if (!canRefresh) return false;
final response = await http.post(
Uri.parse(IntraConfig.tokenUrl),
headers: const {'Accept': 'application/json'},
body: <String, String>{
'grant_type': 'refresh_token',
'client_id': IntraConfig.clientId,
'client_secret': IntraConfig.clientSecret,
'refresh_token': _refreshToken!,
},
);
if (response.statusCode != 200) return false;
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) return false;
final pair = parseOAuthTokenResponse(decoded);
if (pair == null) return false;
_accessToken = pair.accessToken;
if (pair.refreshToken != null) {
_refreshToken = pair.refreshToken;
}
return true;
}
/// Header Authorization pour les appels `api.intra.42.fr`.
@@ -64,15 +126,23 @@ class IntraAuthService {
}
Future<Map<String, dynamic>> fetchMe() async {
final response = await http.get(
final response = await intraApiGet(
Uri.parse('${IntraConfig.apiBaseUrl}/me'),
headers: authHeaders(),
auth: this,
);
if (response.statusCode != 200) {
throw Exception('GET /v2/me a échoué (${response.statusCode})');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
@visibleForTesting
void debugSetTokens({String? access, String? refresh}) {
_accessToken = access;
_refreshToken = refresh;
}
/// Simule un access token expiré (garde le refresh) — tests bonus en debug.
@visibleForTesting
void debugInvalidateAccessToken() {
if (!canRefresh) return;
_accessToken = 'invalid_access_token_for_refresh_test';
}
}

View File

@@ -3,15 +3,26 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'intra_api_exception.dart';
import 'intra_auth_service.dart';
/// GET API 42 avec gestion 401 / 429 (retry + backoff).
/// GET API 42 avec gestion 401 (refresh token), 429 (retry + backoff).
Future<http.Response> intraApiGet(
Uri uri, {
required Map<String, String> headers,
required IntraAuthService auth,
int maxAttempts = 4,
}) async {
var retriedAfterRefresh = false;
for (var attempt = 0; attempt < maxAttempts; attempt++) {
final response = await http.get(uri, headers: headers);
final response = await http.get(uri, headers: auth.authHeaders());
if (response.statusCode == 401) {
if (!retriedAfterRefresh && await auth.refreshAccessToken()) {
retriedAfterRefresh = true;
continue;
}
throw const IntraUnauthorizedException();
}
if (response.statusCode == 429) {
if (attempt + 1 >= maxAttempts) {
@@ -35,7 +46,6 @@ Duration _retryDelay(http.Response response, int attempt) {
return Duration(seconds: seconds.clamp(1, 30));
}
}
// 0.8s, 1.6s, 3.2s…
final ms = 800 * (1 << attempt);
return Duration(milliseconds: ms.clamp(800, 8000));
}

View File

@@ -0,0 +1,21 @@
/// Résultat d’un `POST /oauth/token` (connexion ou refresh).
class OAuthTokenPair {
const OAuthTokenPair({
required this.accessToken,
this.refreshToken,
});
final String accessToken;
final String? refreshToken;
}
OAuthTokenPair? parseOAuthTokenResponse(Map<String, dynamic> json) {
final access = json['access_token'];
if (access is! String || access.isEmpty) return null;
final refresh = json['refresh_token'];
return OAuthTokenPair(
accessToken: access,
refreshToken: refresh is String && refresh.isNotEmpty ? refresh : null,
);
}

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
/// Constantes UI partagées (étape 5 — layout).
abstract final class SwiftyUi {
static const Color brand = Color(0xFF00BABC);
/// Largeur max du contenu sur tablette / paysage.
static const double contentMaxWidth = 960;
/// Largeur max vue recherche (formulaire centré).
static const double searchMaxWidth = 480;
static const double wideBreakpoint = 720;
static EdgeInsets pagePadding(BoxConstraints constraints) {
final horizontal = constraints.maxWidth >= 600 ? 32.0 : 16.0;
return EdgeInsets.fromLTRB(horizontal, 24, horizontal, 24);
}
static PreferredSizeWidget appBar({
required String title,
List<Widget>? actions,
bool showBack = false,
}) {
return AppBar(
title: Text(title),
backgroundColor: brand,
foregroundColor: Colors.black,
automaticallyImplyLeading: showBack,
actions: actions,
);
}
}
/// Corps scrollable centré avec largeur max (clavier + petits écrans).
class ResponsiveScrollBody extends StatelessWidget {
const ResponsiveScrollBody({
super.key,
required this.child,
this.maxWidth = SwiftyUi.contentMaxWidth,
this.center = true,
});
final Widget child;
final double maxWidth;
final bool center;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final content = ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: child,
);
return SingleChildScrollView(
padding: SwiftyUi.pagePadding(constraints),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
child: center
? Align(alignment: Alignment.topCenter, child: content)
: content,
);
},
);
}
}
class ErrorMessage extends StatelessWidget {
const ErrorMessage({super.key, required this.message});
final String message;
@override
Widget build(BuildContext context) {
return SelectableText(
message,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error),
);
}
}
class LoadingFilledButton extends StatelessWidget {
const LoadingFilledButton({
super.key,
required this.label,
required this.loading,
required this.onPressed,
});
final String label;
final bool loading;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: loading ? null : onPressed,
child: loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(label),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:swifty_companion/services/intra_oauth_tokens.dart';
void main() {
group('parseOAuthTokenResponse', () {
test('extrait access et refresh', () {
final pair = parseOAuthTokenResponse({
'access_token': 'new_access',
'refresh_token': 'new_refresh',
'token_type': 'bearer',
});
expect(pair, isNotNull);
expect(pair!.accessToken, 'new_access');
expect(pair.refreshToken, 'new_refresh');
});
test('accepte access seul', () {
final pair = parseOAuthTokenResponse({
'access_token': 'only_access',
});
expect(pair?.accessToken, 'only_access');
expect(pair?.refreshToken, isNull);
});
test('rejette access vide ou absent', () {
expect(parseOAuthTokenResponse({}), isNull);
expect(parseOAuthTokenResponse({'access_token': ''}), isNull);
});
});
}