218 lines
6.5 KiB
Dart
218 lines
6.5 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
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 '../theme/swifty_ui.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: SwiftyUi.appBar(title: 'Swifty Companion'),
|
||
body: const ResponsiveScrollBody(
|
||
maxWidth: SwiftyUi.searchMaxWidth,
|
||
child: Text(
|
||
'Fichier .env manquant dans swifty_companion/.env',
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final isAuthenticated = _auth.isAuthenticated;
|
||
final busy = _connecting || _searching;
|
||
|
||
return Scaffold(
|
||
appBar: SwiftyUi.appBar(
|
||
title: 'Swifty Companion',
|
||
actions: [
|
||
if (isAuthenticated)
|
||
IconButton(
|
||
onPressed: busy ? null : _disconnect,
|
||
icon: const Icon(Icons.logout),
|
||
tooltip: 'Déconnexion API',
|
||
),
|
||
],
|
||
),
|
||
body: ResponsiveScrollBody(
|
||
maxWidth: SwiftyUi.searchMaxWidth,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (!isAuthenticated) ...[
|
||
Text(
|
||
'Connexion API 42',
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Connecte-toi une fois ; le token servira pour toutes les recherches.',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
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: 28),
|
||
Text(
|
||
'Rechercher un étudiant',
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _loginController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Login 42',
|
||
hintText: 'ex. fgras-ca',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
textInputAction: TextInputAction.search,
|
||
autocorrect: false,
|
||
enableSuggestions: false,
|
||
onSubmitted: (_) {
|
||
if (!_searching) _search();
|
||
},
|
||
enabled: !_searching,
|
||
),
|
||
const SizedBox(height: 16),
|
||
LoadingFilledButton(
|
||
label: 'Rechercher',
|
||
loading: _searching,
|
||
onPressed: _search,
|
||
),
|
||
// Bonus refresh : invalide l'access token en mémoire (build debug uniquement).
|
||
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: 20),
|
||
ErrorMessage(message: _error!),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|