Files
swift_companion/swiftyCompanion/swifty_companion/lib/screens/profile_screen.dart
Ladebeze66 a6ab13ee2f fix(profil): photo CDN avec Bearer OAuth et doc soutenance
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>
2026-10-21 10:38:16 +02:00

488 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
],
),
);
}),
],
),
),
);
}
}