Compare commits
2 Commits
7a06d891b4
...
soutenance
| Author | SHA1 | Date | |
|---|---|---|---|
| eaacef0cb6 | |||
| 63886d2df5 |
@@ -53,6 +53,8 @@ Documentation API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
|
|||||||
|
|
||||||
Certaines données peuvent s’afficher comme « — » : l’API peut masquer des champs selon la confidentialité.
|
Certaines données peuvent s’afficher comme « — » : l’API peut masquer des champs selon la confidentialité.
|
||||||
|
|
||||||
|
**Photo :** l’URL est fournie par l’API (`image` sur la fiche user) ; le fichier JPEG est chargé depuis `cdn.intra.42.fr` avec le **même** access token OAuth (pas un second login).
|
||||||
|
|
||||||
## Vérifications avant soutenance
|
## Vérifications avant soutenance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Copier en .env dans CE dossier (swifty_companion/) :
|
|
||||||
# cp .env.example .env
|
|
||||||
# Puis éditer .env avec ton UID, SECRET et REDIRECT_URI Intra.
|
|
||||||
#
|
|
||||||
# Modèle détaillé (mêmes variables) : ../.env.example à la racine du dépôt.
|
|
||||||
|
|
||||||
CLIENT_ID=
|
|
||||||
CLIENT_SECRET=
|
|
||||||
REDIRECT_URI=com.example.swiftycompanion://callback
|
|
||||||
@@ -5,8 +5,8 @@ Le projet s’exécute depuis ce dossier.
|
|||||||
## Démarrage rapide
|
## Démarrage rapide
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # ou : cp ../.env.example .env
|
cp ../.env.example .env
|
||||||
# Éditer .env (CLIENT_ID, CLIENT_SECRET, REDIRECT_URI)
|
# Éditer .env (CLIENT_ID, CLIENT_SECRET, REDIRECT_URI) — voir commentaires dans ../.env.example
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run -d <device>
|
flutter run -d <device>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../services/intra_api_exception.dart';
|
import '../services/intra_api_exception.dart';
|
||||||
|
import '../services/intra_auth_service.dart';
|
||||||
import '../services/intra_profile_service.dart';
|
import '../services/intra_profile_service.dart';
|
||||||
import '../theme/swifty_ui.dart';
|
import '../theme/swifty_ui.dart';
|
||||||
|
|
||||||
@@ -56,11 +60,22 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
String? _imageUrl(Map<String, dynamic> user) {
|
||||||
final image = user['image'];
|
final image = user['image'];
|
||||||
if (image is Map<String, dynamic>) {
|
if (image is Map<String, dynamic>) {
|
||||||
final link = image['link'];
|
final link = image['link'];
|
||||||
if (link is String && link.isNotEmpty) return 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'];
|
final url = user['image_url'];
|
||||||
if (url is String && url.isNotEmpty) return url;
|
if (url is String && url.isNotEmpty) return url;
|
||||||
@@ -172,6 +187,71 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
class _ErrorBody extends StatelessWidget {
|
||||||
const _ErrorBody({required this.message, required this.onRetry});
|
const _ErrorBody({required this.message, required this.onRetry});
|
||||||
|
|
||||||
@@ -206,17 +286,7 @@ class _ProfileHeader extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
_IntraProfileAvatar(login: login, imageUrl: imageUrl),
|
||||||
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),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ class IntraAuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// En-têtes pour télécharger la photo sur `cdn.intra.42.fr`.
|
||||||
|
///
|
||||||
|
/// L’URL vient du JSON user (`image.link` / `versions`). Le CDN refuse souvent
|
||||||
|
/// les requêtes sans Bearer (403). On réutilise le **même** access token que pour
|
||||||
|
/// l’API — pas un second OAuth. On n’envoie pas `Accept: application/json` ici.
|
||||||
|
Map<String, String> cdnImageHeaders() {
|
||||||
|
if (!isAuthenticated) return const {};
|
||||||
|
return {'Authorization': 'Bearer $_accessToken'};
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> fetchMe() async {
|
Future<Map<String, dynamic>> fetchMe() async {
|
||||||
final response = await intraApiGet(
|
final response = await intraApiGet(
|
||||||
Uri.parse('${IntraConfig.apiBaseUrl}/me'),
|
Uri.parse('${IntraConfig.apiBaseUrl}/me'),
|
||||||
|
|||||||
Reference in New Issue
Block a user