guidesoutenance
This commit is contained in:
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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!),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
109
swiftyCompanion/swifty_companion/lib/theme/swifty_ui.dart
Normal file
109
swiftyCompanion/swifty_companion/lib/theme/swifty_ui.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user