etape4
This commit is contained in:
@@ -4,7 +4,7 @@ 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.
|
||||
|
||||
**État :** projet Flutter dans le sous-dossier `swifty_companion/`. Prochaine étape : OAuth Intra (dépôt de travail : `docs/pedagogie/etape-02-oauth-intra.md`).
|
||||
**É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`).
|
||||
|
||||
## Lancer le projet
|
||||
|
||||
@@ -17,7 +17,7 @@ flutter pub get
|
||||
flutter run -d <device>
|
||||
```
|
||||
|
||||
Écran actuel : **connexion OAuth 42** puis affichage du login (`/v2/me`).
|
||||
Écrans : **connexion OAuth 42** → **recherche par login** → **profil** (photo, champs, skills, projets validés / failed).
|
||||
|
||||
Vérifications :
|
||||
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Vue 2 — squelette étape 3 ; détails complets à l’étape 4.
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
import '../services/intra_api_exception.dart';
|
||||
import '../services/intra_profile_service.dart';
|
||||
|
||||
/// Vue 2 — profil complet (étape 4).
|
||||
class ProfileScreen extends StatefulWidget {
|
||||
const ProfileScreen({super.key, required this.user});
|
||||
|
||||
/// Résultat minimal de la recherche (au moins `id` + `login`).
|
||||
final Map<String, dynamic> user;
|
||||
|
||||
String? _imageUrl() {
|
||||
@override
|
||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||
}
|
||||
|
||||
class _ProfileScreenState extends State<ProfileScreen> {
|
||||
final _profiles = IntraProfileService.instance;
|
||||
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
IntraUserProfile? _profile;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final profile = await _profiles.fetchProfile(widget.user);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_profile = profile;
|
||||
_loading = false;
|
||||
});
|
||||
} on IntraApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.message;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String? _imageUrl(Map<String, dynamic> user) {
|
||||
final image = user['image'];
|
||||
if (image is Map<String, dynamic>) {
|
||||
final link = image['link'];
|
||||
@@ -17,10 +66,17 @@ class ProfileScreen extends StatelessWidget {
|
||||
return null;
|
||||
}
|
||||
|
||||
String _display(dynamic value, {String fallback = '—'}) {
|
||||
if (value == null) return fallback;
|
||||
final text = value.toString().trim();
|
||||
return text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final login = user['login']?.toString() ?? '?';
|
||||
final imageUrl = _imageUrl();
|
||||
final user = _profile?.user ?? widget.user;
|
||||
final login = _display(user['login'], fallback: '?');
|
||||
final imageUrl = _imageUrl(user);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -28,39 +84,175 @@ class ProfileScreen extends StatelessWidget {
|
||||
backgroundColor: const Color(0xFF00BABC),
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorBody(message: _error!, onRetry: _load)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = constraints.maxWidth >= 720;
|
||||
final profile = _profile!;
|
||||
final header = _ProfileHeader(
|
||||
login: login,
|
||||
imageUrl: imageUrl,
|
||||
);
|
||||
final details = _DetailsCard(
|
||||
user: profile.user,
|
||||
cursusLevel: profile.cursusLevel,
|
||||
display: _display,
|
||||
);
|
||||
final skills = _SkillsCard(skills: profile.skills);
|
||||
final projects = _ProjectsCard(projects: profile.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,
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBody extends StatelessWidget {
|
||||
const _ErrorBody({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileHeader extends StatelessWidget {
|
||||
const _ProfileHeader({required this.login, required this.imageUrl});
|
||||
|
||||
final String login;
|
||||
final String? imageUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
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: 12),
|
||||
Text(
|
||||
login,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailsCard extends StatelessWidget {
|
||||
const _DetailsCard({
|
||||
required this.user,
|
||||
required this.cursusLevel,
|
||||
required this.display,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> user;
|
||||
final double? cursusLevel;
|
||||
final String Function(dynamic value, {String fallback}) display;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final levelText = cursusLevel != null
|
||||
? cursusLevel!.toStringAsFixed(2)
|
||||
: display(user['level']);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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,
|
||||
'Informations',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Étape 4 à venir : email, level, skills, projets (dont failed), etc.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
const SizedBox(height: 12),
|
||||
_DetailRow(label: 'Email', value: display(user['email'])),
|
||||
_DetailRow(label: 'Téléphone', value: display(user['phone'])),
|
||||
_DetailRow(label: 'Niveau cursus', value: levelText),
|
||||
_DetailRow(
|
||||
label: 'Localisation',
|
||||
value: display(user['location']),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ID Intra : ${user['id'] ?? '—'}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
_DetailRow(
|
||||
label: 'Wallet',
|
||||
value: display(user['wallet']),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -68,3 +260,120 @@ class ProfileScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@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),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkillsCard extends StatelessWidget {
|
||||
const _SkillsCard({required this.skills});
|
||||
|
||||
final List<IntraSkillSummary> skills;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Skills', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
if (skills.isEmpty)
|
||||
const Text('Aucune compétence disponible.')
|
||||
else
|
||||
...skills.map(
|
||||
(s) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${s.name} — niveau ${s.level} (${s.percentage} %)'),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: (s.level + s.percentage / 100).clamp(0, 15) / 15,
|
||||
minHeight: 6,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
color: const Color(0xFF00BABC),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectsCard extends StatelessWidget {
|
||||
const _ProjectsCard({required this.projects});
|
||||
|
||||
final List<IntraProjectSummary> projects;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Projets (validés et failed)',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (projects.isEmpty)
|
||||
const Text('Aucun projet terminé ou en échec.')
|
||||
else
|
||||
...projects.map((p) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,3 +30,18 @@ class IntraUnauthorizedException extends IntraApiException {
|
||||
class IntraApiErrorException extends IntraApiException {
|
||||
const IntraApiErrorException(super.message);
|
||||
}
|
||||
|
||||
class IntraRateLimitException extends IntraApiException {
|
||||
const IntraRateLimitException()
|
||||
: super(
|
||||
'Trop de requêtes vers l’API 42. Attends quelques secondes puis réessaie.',
|
||||
);
|
||||
}
|
||||
|
||||
class IntraForbiddenException extends IntraApiException {
|
||||
const IntraForbiddenException()
|
||||
: super(
|
||||
'Accès refusé (403). Déconnecte-toi puis reconnecte-toi '
|
||||
'(scopes OAuth : public + projects sur l’app Intra).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ class IntraAuthService {
|
||||
IntraConfig.redirectUri,
|
||||
clientSecret: IntraConfig.clientSecret,
|
||||
serviceConfiguration: _serviceConfiguration,
|
||||
scopes: ['public'],
|
||||
// `projects` requis pour projects_users ; skills via cursus_users (pas /experiences).
|
||||
scopes: ['public', 'projects'],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'intra_api_exception.dart';
|
||||
|
||||
/// GET API 42 avec gestion 401 / 429 (retry + backoff).
|
||||
Future<http.Response> intraApiGet(
|
||||
Uri uri, {
|
||||
required Map<String, String> headers,
|
||||
int maxAttempts = 4,
|
||||
}) async {
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
final response = await http.get(uri, headers: headers);
|
||||
|
||||
if (response.statusCode == 429) {
|
||||
if (attempt + 1 >= maxAttempts) {
|
||||
throw const IntraRateLimitException();
|
||||
}
|
||||
await Future<void>.delayed(_retryDelay(response, attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
throw const IntraRateLimitException();
|
||||
}
|
||||
|
||||
Duration _retryDelay(http.Response response, int attempt) {
|
||||
final header = response.headers['retry-after'];
|
||||
if (header != null) {
|
||||
final seconds = int.tryParse(header.trim());
|
||||
if (seconds != null && seconds > 0) {
|
||||
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));
|
||||
}
|
||||
|
||||
void ensureIntraOk(http.Response response) {
|
||||
if (response.statusCode == 401) {
|
||||
throw const IntraUnauthorizedException();
|
||||
}
|
||||
if (response.statusCode == 403) {
|
||||
throw const IntraForbiddenException();
|
||||
}
|
||||
if (response.statusCode == 429) {
|
||||
throw const IntraRateLimitException();
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
throw IntraApiErrorException(
|
||||
'Erreur API (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dynamic decodeIntraBody(http.Response response) {
|
||||
ensureIntraOk(response);
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/intra_config.dart';
|
||||
import 'intra_api_exception.dart';
|
||||
import 'intra_auth_service.dart';
|
||||
import 'intra_http.dart';
|
||||
|
||||
/// Compétence Intra : niveau entier + pourcentage (partie décimale du level API).
|
||||
class IntraSkillSummary {
|
||||
const IntraSkillSummary({
|
||||
required this.name,
|
||||
required this.level,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int level;
|
||||
final int percentage;
|
||||
}
|
||||
|
||||
/// Projet terminé ou en échec.
|
||||
class IntraProjectSummary {
|
||||
const IntraProjectSummary({
|
||||
required this.name,
|
||||
required this.status,
|
||||
this.finalMark,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String status;
|
||||
final int? finalMark;
|
||||
}
|
||||
|
||||
/// Données agrégées pour la vue profil.
|
||||
class IntraUserProfile {
|
||||
const IntraUserProfile({
|
||||
required this.user,
|
||||
required this.cursusLevel,
|
||||
required this.skills,
|
||||
required this.projects,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> user;
|
||||
final double? cursusLevel;
|
||||
final List<IntraSkillSummary> skills;
|
||||
final List<IntraProjectSummary> projects;
|
||||
}
|
||||
|
||||
class _ProfileCacheEntry {
|
||||
_ProfileCacheEntry(this.profile, this.fetchedAt);
|
||||
|
||||
final IntraUserProfile profile;
|
||||
final DateTime fetchedAt;
|
||||
}
|
||||
|
||||
class IntraProfileService {
|
||||
IntraProfileService._();
|
||||
static final IntraProfileService instance = IntraProfileService._();
|
||||
|
||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
||||
static const Duration _betweenRequests = Duration(milliseconds: 450);
|
||||
|
||||
final IntraAuthService _auth = IntraAuthService.instance;
|
||||
final Map<String, _ProfileCacheEntry> _cache = {};
|
||||
|
||||
/// Charge profil complet (user, cursus, skills, projets) avec le token existant.
|
||||
Future<IntraUserProfile> fetchProfile(Map<String, dynamic> summaryUser) async {
|
||||
if (!_auth.isAuthenticated) {
|
||||
throw const IntraNotAuthenticatedException();
|
||||
}
|
||||
|
||||
final userId = summaryUser['id'];
|
||||
if (userId == null) {
|
||||
throw const IntraApiErrorException('Identifiant utilisateur manquant');
|
||||
}
|
||||
final id = userId.toString();
|
||||
|
||||
final cached = _cache[id];
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.fetchedAt) < _cacheTtl) {
|
||||
return cached.profile;
|
||||
}
|
||||
|
||||
try {
|
||||
final login = summaryUser['login']?.toString();
|
||||
final userPath = login != null && login.isNotEmpty
|
||||
? '/users/$login'
|
||||
: '/users/$id';
|
||||
|
||||
final userDecoded = await _getJson(userPath);
|
||||
if (userDecoded is! Map<String, dynamic>) {
|
||||
throw IntraApiErrorException('Réponse API inattendue ($userPath)');
|
||||
}
|
||||
|
||||
final embeddedCursus = userDecoded['cursus_users'];
|
||||
final List<dynamic> cursusList;
|
||||
if (embeddedCursus is List && embeddedCursus.isNotEmpty) {
|
||||
cursusList = embeddedCursus;
|
||||
} else {
|
||||
await Future<void>.delayed(_betweenRequests);
|
||||
cursusList = await _getJsonList('/users/$id/cursus_users');
|
||||
}
|
||||
|
||||
await Future<void>.delayed(_betweenRequests);
|
||||
final projectsUsers = await _getJsonList(
|
||||
'/users/$id/projects_users',
|
||||
query: {'page[size]': '100'},
|
||||
);
|
||||
final primaryCursus = _pickPrimaryCursus(cursusList);
|
||||
|
||||
final profile = IntraUserProfile(
|
||||
user: userDecoded,
|
||||
cursusLevel: _levelFromCursus(primaryCursus),
|
||||
skills: _parseSkillsFromCursus(primaryCursus, cursusList),
|
||||
projects: _parseProjects(projectsUsers),
|
||||
);
|
||||
|
||||
_cache[id] = _ProfileCacheEntry(profile, DateTime.now());
|
||||
return profile;
|
||||
} on IntraApiException {
|
||||
rethrow;
|
||||
} on SocketException {
|
||||
throw const IntraNetworkException();
|
||||
} on http.ClientException {
|
||||
throw const IntraNetworkException();
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _getJson(String path, {Map<String, String>? query}) async {
|
||||
final uri = Uri.parse('${IntraConfig.apiBaseUrl}$path').replace(
|
||||
queryParameters: query,
|
||||
);
|
||||
final response = await intraApiGet(
|
||||
uri,
|
||||
headers: _auth.authHeaders(),
|
||||
);
|
||||
return decodeIntraBody(response);
|
||||
}
|
||||
|
||||
Future<List<dynamic>> _getJsonList(
|
||||
String path, {
|
||||
Map<String, String>? query,
|
||||
}) async {
|
||||
final decoded = await _getJson(path, query: query);
|
||||
if (decoded is! List) {
|
||||
throw IntraApiErrorException('Réponse API inattendue ($path)');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _pickPrimaryCursus(List<dynamic> cursusUsers) {
|
||||
Map<String, dynamic>? fallback;
|
||||
for (final entry in cursusUsers) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final cursus = entry['cursus'];
|
||||
final slug = cursus is Map ? cursus['slug']?.toString() : null;
|
||||
final name = cursus is Map ? cursus['name']?.toString() : null;
|
||||
if (slug == '42' || name == '42' || name == '42cursus') {
|
||||
return entry;
|
||||
}
|
||||
fallback ??= entry;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
double? _levelFromCursus(Map<String, dynamic>? cursusUser) {
|
||||
if (cursusUser == null) return null;
|
||||
final level = cursusUser['level'];
|
||||
return level is num ? level.toDouble() : null;
|
||||
}
|
||||
|
||||
List<IntraSkillSummary> _parseSkillsFromCursus(
|
||||
Map<String, dynamic>? primaryCursus,
|
||||
List<dynamic> allCursus,
|
||||
) {
|
||||
final sources = <Map<String, dynamic>>[];
|
||||
if (primaryCursus != null) {
|
||||
sources.add(primaryCursus);
|
||||
} else {
|
||||
for (final c in allCursus) {
|
||||
if (c is Map<String, dynamic>) sources.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
final byId = <int, IntraSkillSummary>{};
|
||||
for (final cursusUser in sources) {
|
||||
final skills = cursusUser['skills'];
|
||||
if (skills is! List) continue;
|
||||
for (final skill in skills) {
|
||||
if (skill is! Map<String, dynamic>) continue;
|
||||
final idRaw = skill['id'];
|
||||
if (idRaw is! num) continue;
|
||||
final skillId = idRaw.toInt();
|
||||
final name = skill['name']?.toString() ?? 'Skill $skillId';
|
||||
final levelRaw = skill['level'];
|
||||
if (levelRaw is! num) continue;
|
||||
final summary = _skillFromLevel(name, levelRaw.toDouble());
|
||||
final existing = byId[skillId];
|
||||
if (existing == null || summary.level > existing.level) {
|
||||
byId[skillId] = summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final list = byId.values.toList();
|
||||
list.sort((a, b) => b.level.compareTo(a.level));
|
||||
return list;
|
||||
}
|
||||
|
||||
IntraSkillSummary _skillFromLevel(String name, double levelValue) {
|
||||
final whole = levelValue.floor();
|
||||
final fraction = levelValue - whole;
|
||||
final percentage = (fraction * 100).round().clamp(0, 99);
|
||||
return IntraSkillSummary(
|
||||
name: name,
|
||||
level: whole,
|
||||
percentage: percentage,
|
||||
);
|
||||
}
|
||||
|
||||
List<IntraProjectSummary> _parseProjects(List<dynamic> projectsUsers) {
|
||||
final projects = <IntraProjectSummary>[];
|
||||
|
||||
for (final entry in projectsUsers) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final status = entry['status']?.toString();
|
||||
if (status != 'finished' && status != 'failed') continue;
|
||||
|
||||
final project = entry['project'];
|
||||
final name = project is Map
|
||||
? (project['name']?.toString() ?? project['slug']?.toString())
|
||||
: null;
|
||||
if (name == null || name.isEmpty) continue;
|
||||
|
||||
final mark = entry['final_mark'];
|
||||
projects.add(
|
||||
IntraProjectSummary(
|
||||
name: name,
|
||||
status: status!,
|
||||
finalMark: mark is int ? mark : (mark is num ? mark.round() : null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
projects.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return projects;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
||||
import '../config/intra_config.dart';
|
||||
import 'intra_api_exception.dart';
|
||||
import 'intra_auth_service.dart';
|
||||
import 'intra_http.dart';
|
||||
|
||||
class IntraUserService {
|
||||
IntraUserService._();
|
||||
@@ -28,7 +29,10 @@ class IntraUserService {
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await http.get(uri, headers: _auth.authHeaders());
|
||||
final response = await intraApiGet(
|
||||
uri,
|
||||
headers: _auth.authHeaders(),
|
||||
);
|
||||
return _parseUserListResponse(response, trimmed);
|
||||
} on IntraApiException {
|
||||
rethrow;
|
||||
@@ -43,14 +47,7 @@ class IntraUserService {
|
||||
http.Response response,
|
||||
String login,
|
||||
) {
|
||||
if (response.statusCode == 401) {
|
||||
throw const IntraUnauthorizedException();
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
throw IntraApiErrorException(
|
||||
'Erreur API (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
ensureIntraOk(response);
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! List) {
|
||||
|
||||
Reference in New Issue
Block a user