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

@@ -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!),
],
],
),