diff --git a/eme-frontend/lib/screens/responsable_screen.dart b/eme-frontend/lib/screens/responsable_screen.dart index 32536d4..b1490f2 100644 --- a/eme-frontend/lib/screens/responsable_screen.dart +++ b/eme-frontend/lib/screens/responsable_screen.dart @@ -4,6 +4,7 @@ import "package:google_fonts/google_fonts.dart"; import "../demo_identity.dart"; import "../services/responsable_service.dart"; import "../theme/ensup_colors.dart"; +import "../utils/file_download.dart"; const _workspaceBackground = Color(0xFFF3F6F9); const _orange = Color(0xFFD97706); @@ -19,6 +20,7 @@ class _ResponsableScreenState extends State { late Future<_ResponsableData> _future = _charger(); int _onglet = 0; bool _transitionEnCours = false; + bool _exportEnCours = false; Future<_ResponsableData> _charger() async { final dashboard = await ResponsableService.dashboard(); @@ -79,6 +81,33 @@ class _ResponsableScreenState extends State { } } + Future _exporterHistorique() async { + if (_exportEnCours) return; + + setState(() => _exportEnCours = true); + try { + final file = await ResponsableService.exporterHistorique(); + downloadFile( + bytes: file.bytes, + fileName: file.fileName, + contentType: file.contentType, + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text("Export CSV téléchargé."))); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } finally { + if (mounted) { + setState(() => _exportEnCours = false); + } + } + } + String? _prochainStatut(String? statut) { return switch (statut) { "DETECTEE" => "EN_COURS_TRAITEMENT", @@ -195,17 +224,15 @@ class _ResponsableScreenState extends State { items: data.historique, builder: _historiqueTile, action: OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "Export disponible via /api/responsable/historique/export.csv", - ), - ), - ); - }, - icon: const Icon(Icons.download_outlined, size: 16), - label: const Text("Export CSV"), + onPressed: _exportEnCours ? null : _exporterHistorique, + icon: _exportEnCours + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 16), + label: Text(_exportEnCours ? "Export en cours" : "Export CSV"), ), searchableText: (item) => "${item["action"]} ${item["description"]}", ), diff --git a/eme-frontend/lib/services/api_client.dart b/eme-frontend/lib/services/api_client.dart index ed32478..c7d7d74 100644 --- a/eme-frontend/lib/services/api_client.dart +++ b/eme-frontend/lib/services/api_client.dart @@ -1,4 +1,5 @@ import "dart:convert"; +import "dart:typed_data"; import "package:http/http.dart" as http; /// Erreur levée par le client API (serveur injoignable ou réponse en échec). @@ -11,6 +12,18 @@ class ApiException implements Exception { String toString() => message; } +class ApiFile { + const ApiFile({ + required this.bytes, + required this.fileName, + required this.contentType, + }); + + final Uint8List bytes; + final String fileName; + final String contentType; +} + /// Point d'accès unique au backend : centralise l'URL de base, l'en-tête /// d'authentification (simulée) et la gestion des erreurs. class ApiClient { @@ -44,6 +57,37 @@ class ApiClient { throw ApiException(_messageErreur(reponse)); } + static Future getFile( + String chemin, { + required String fallbackFileName, + String utilisateurEmail = _utilisateurEmail, + }) async { + final uri = Uri.parse("$_baseUrl$chemin"); + + late final http.Response reponse; + try { + reponse = await http.get( + uri, + headers: {"x-user-email": utilisateurEmail}, + ); + } catch (_) { + throw const ApiException( + "Impossible de joindre le serveur. Vérifiez que le backend est démarré.", + ); + } + + if (reponse.statusCode < 200 || reponse.statusCode >= 300) { + throw ApiException(_messageErreur(reponse)); + } + + return ApiFile( + bytes: reponse.bodyBytes, + fileName: _fileName(reponse, fallbackFileName), + contentType: + reponse.headers["content-type"] ?? "application/octet-stream", + ); + } + static Future post( String chemin, Map body, { @@ -116,4 +160,15 @@ class ApiClient { } return "Erreur ${reponse.statusCode}"; } + + static String _fileName(http.Response reponse, String fallback) { + final disposition = reponse.headers["content-disposition"]; + if (disposition == null) return fallback; + + final match = RegExp( + "filename=\"?([^\";]+)\"?", + caseSensitive: false, + ).firstMatch(disposition); + return match?.group(1) ?? fallback; + } } diff --git a/eme-frontend/lib/services/responsable_service.dart b/eme-frontend/lib/services/responsable_service.dart index 5bec702..62e6b6b 100644 --- a/eme-frontend/lib/services/responsable_service.dart +++ b/eme-frontend/lib/services/responsable_service.dart @@ -29,6 +29,12 @@ class ResponsableService { static Future>> historique() => _liste("/responsable/historique"); + static Future exporterHistorique() => ApiClient.getFile( + "/responsable/historique/export.csv", + fallbackFileName: "historique-responsable.csv", + utilisateurEmail: _email, + ); + static Future marquerNotificationsLues() async { final reponse = await ApiClient.patch( "/responsable/notifications/lu-toutes", diff --git a/eme-frontend/lib/utils/file_download.dart b/eme-frontend/lib/utils/file_download.dart new file mode 100644 index 0000000..810c5a3 --- /dev/null +++ b/eme-frontend/lib/utils/file_download.dart @@ -0,0 +1,21 @@ +// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use + +import "dart:html" as html; +import "dart:typed_data"; + +void downloadFile({ + required Uint8List bytes, + required String fileName, + required String contentType, +}) { + final blob = html.Blob([bytes], contentType); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..download = fileName + ..style.display = "none"; + + html.document.body?.children.add(anchor); + anchor.click(); + anchor.remove(); + html.Url.revokeObjectUrl(url); +}