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

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