Charge les avatars depuis cdn.intra.42.fr avec le même access token, parse image.versions, et documente le flux pour la correction. Co-authored-by: Cursor <cursoragent@cursor.com>
488 lines
15 KiB
Dart
488 lines
15 KiB
Dart
import 'dart:typed_data';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:http/http.dart' as http;
|
||
|
||
import '../services/intra_api_exception.dart';
|
||
import '../services/intra_auth_service.dart';
|
||
import '../services/intra_profile_service.dart';
|
||
import '../theme/swifty_ui.dart';
|
||
|
||
/// Vue 2 — photo, ≥ 4 champs, skills (niveau + %), projets validés et failed.
|
||
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;
|
||
|
||
@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;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Extrait l’URL photo depuis la fiche `GET /v2/users/{login}` (critère sujet : photo de profil).
|
||
///
|
||
/// Ordre : `image.link` → `image.versions` (large…micro) → champ legacy `image_url`.
|
||
String? _imageUrl(Map<String, dynamic> user) {
|
||
final image = user['image'];
|
||
if (image is Map<String, dynamic>) {
|
||
final link = image['link'];
|
||
if (link is String && link.isNotEmpty) return link;
|
||
|
||
final versions = image['versions'];
|
||
if (versions is Map<String, dynamic>) {
|
||
for (final key in ['large', 'medium', 'small', 'micro']) {
|
||
final v = versions[key];
|
||
if (v is String && v.isNotEmpty) return v;
|
||
}
|
||
}
|
||
}
|
||
final url = user['image_url'];
|
||
if (url is String && url.isNotEmpty) return url;
|
||
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 user = _profile?.user ?? widget.user;
|
||
final login = _display(user['login'], fallback: '?');
|
||
final imageUrl = _imageUrl(user);
|
||
|
||
return Scaffold(
|
||
appBar: SwiftyUi.appBar(title: login, showBack: true),
|
||
body: _loading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _error != null
|
||
? ResponsiveScrollBody(
|
||
maxWidth: SwiftyUi.searchMaxWidth,
|
||
child: _ErrorBody(message: _error!, onRetry: _load),
|
||
)
|
||
: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final wide =
|
||
constraints.maxWidth >= SwiftyUi.wideBreakpoint;
|
||
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);
|
||
|
||
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: SwiftyUi.pagePadding(constraints),
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(
|
||
maxWidth: SwiftyUi.contentMaxWidth,
|
||
),
|
||
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: SwiftyUi.pagePadding(constraints),
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(
|
||
maxWidth: SwiftyUi.contentMaxWidth,
|
||
),
|
||
child: column,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Avatar profil : charge l’image via HTTP avec le Bearer OAuth (`IntraAuthService.cdnImageHeaders`).
|
||
///
|
||
/// Un simple `NetworkImage(url)` sans token échoue souvent sur le CDN 42. Si le chargement
|
||
/// échoue ou l’URL est absente, affiche l’initiale du login (conforme encadré confidentialité sujet).
|
||
class _IntraProfileAvatar extends StatefulWidget {
|
||
const _IntraProfileAvatar({required this.login, required this.imageUrl});
|
||
|
||
final String login;
|
||
final String? imageUrl;
|
||
|
||
@override
|
||
State<_IntraProfileAvatar> createState() => _IntraProfileAvatarState();
|
||
}
|
||
|
||
class _IntraProfileAvatarState extends State<_IntraProfileAvatar> {
|
||
Uint8List? _bytes;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant _IntraProfileAvatar oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.imageUrl != widget.imageUrl) {
|
||
_bytes = null;
|
||
_load();
|
||
}
|
||
}
|
||
|
||
/// GET binaire sur l’URL CDN ; même session que les appels `api.intra.42.fr`.
|
||
Future<void> _load() async {
|
||
final url = widget.imageUrl;
|
||
if (url == null || url.isEmpty) return;
|
||
|
||
final headers = IntraAuthService.instance.cdnImageHeaders();
|
||
try {
|
||
final response = await http.get(Uri.parse(url), headers: headers);
|
||
if (response.statusCode == 200 && mounted) {
|
||
setState(() => _bytes = response.bodyBytes);
|
||
}
|
||
} catch (_) {
|
||
// Initiale affichée en repli.
|
||
}
|
||
}
|
||
|
||
Widget _initial() {
|
||
final letter =
|
||
widget.login.isNotEmpty ? widget.login[0].toUpperCase() : '?';
|
||
return Text(letter, style: const TextStyle(fontSize: 32));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CircleAvatar(
|
||
radius: 56,
|
||
backgroundImage:
|
||
_bytes != null ? MemoryImage(_bytes!) : null,
|
||
child: _bytes == null ? _initial() : null,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ErrorBody extends StatelessWidget {
|
||
const _ErrorBody({required this.message, required this.onRetry});
|
||
|
||
final String message;
|
||
final VoidCallback onRetry;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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: [
|
||
_IntraProfileAvatar(login: login, imageUrl: imageUrl),
|
||
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.start,
|
||
children: [
|
||
Text(
|
||
'Informations',
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
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']),
|
||
),
|
||
_DetailRow(
|
||
label: 'Wallet',
|
||
value: display(user['wallet']),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DetailRow extends StatelessWidget {
|
||
const _DetailRow({required this.label, required this.value});
|
||
|
||
final String label;
|
||
final String value;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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)),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
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: SwiftyUi.brand,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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 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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|