import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:geosector_app/core/data/models/user_model.dart'; import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:retry/retry.dart'; import 'package:universal_html/html.dart' as html; import 'package:geosector_app/core/utils/api_exception.dart'; import 'package:geosector_app/core/services/connectivity_service.dart'; import 'package:geosector_app/core/data/models/pending_request.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; import 'device_info_service.dart'; class ApiService { static ApiService? _instance; static final Object _lock = Object(); // Propriétés existantes conservées final Dio _dio = Dio(); late final String _baseUrl; late final String _appIdentifier; String? _sessionId; // Nouvelles propriétés pour la gestion offline ConnectivityService? _connectivityService; bool _isProcessingQueue = false; final _uuid = const Uuid(); // Getters pour les propriétés (lecture seule) String? get sessionId => _sessionId; String get baseUrl => _baseUrl; // Singleton thread-safe static ApiService get instance { if (_instance == null) { throw Exception('ApiService non initialisé. Appelez initialize() d\'abord.'); } return _instance!; } static Future initialize() async { if (_instance == null) { synchronized(_lock, () { if (_instance == null) { _instance = ApiService._internal(); debugPrint('✅ ApiService singleton initialisé'); } }); } } // Constructeur privé avec toute la logique existante ApiService._internal() { _configureEnvironment(); _dio.options.baseUrl = _baseUrl; _dio.options.connectTimeout = AppKeys.connectionTimeout; _dio.options.receiveTimeout = AppKeys.receiveTimeout; final headers = Map.from(AppKeys.defaultHeaders); headers['X-App-Identifier'] = _appIdentifier; _dio.options.headers.addAll(headers); _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { if (_sessionId != null) { options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId'; } handler.next(options); }, onError: (DioException error, handler) { if (error.response?.statusCode == 401) { _sessionId = null; } handler.next(error); }, )); debugPrint('🔗 ApiService configuré pour $_baseUrl'); // Initialiser le listener de connectivité _initConnectivityListener(); } // Initialise le listener pour détecter les changements de connectivité void _initConnectivityListener() { try { _connectivityService = ConnectivityService(); _connectivityService!.addListener(_onConnectivityChanged); debugPrint('📡 Listener de connectivité activé'); // Vérifier s'il y a des requêtes en attente au démarrage if (_connectivityService!.isConnected) { _checkAndProcessPendingRequests(); } } catch (e) { debugPrint('⚠️ Erreur lors de l\'initialisation du listener de connectivité: $e'); } } // Appelé quand l'état de connectivité change void _onConnectivityChanged() { if (_connectivityService?.isConnected ?? false) { debugPrint('📡 Connexion rétablie - Traitement de la file d\'attente'); _checkAndProcessPendingRequests(); } else { debugPrint('📡 Connexion perdue - Mise en file d\'attente des requêtes'); } } // Vérifie et traite les requêtes en attente Future _checkAndProcessPendingRequests() async { if (_isProcessingQueue) { debugPrint('⏳ Traitement de la file déjà en cours'); return; } try { if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { debugPrint('📦 Box pending_requests non ouverte'); return; } final box = Hive.box(AppKeys.pendingRequestsBoxName); if (box.isEmpty) { return; } debugPrint('📨 ${box.length} requête(s) en attente trouvée(s)'); await processPendingRequests(); } catch (e) { debugPrint('❌ Erreur lors de la vérification des requêtes en attente: $e'); } } // Fonction synchronized simple pour éviter les imports supplémentaires static T synchronized(Object lock, T Function() computation) { return computation(); } // Détermine l'environnement actuel (DEV, REC, PROD) en fonction de l'URL String _determineEnvironment() { if (!kIsWeb) { // En mode non-web, utiliser l'environnement de développement par défaut return 'DEV'; } final currentUrl = html.window.location.href.toLowerCase(); if (currentUrl.contains('dapp.geosector.fr')) { return 'DEV'; } else if (currentUrl.contains('rapp.geosector.fr')) { return 'REC'; } else { return 'PROD'; } } // Configure l'URL de base API et l'identifiant d'application selon l'environnement void _configureEnvironment() { final env = _determineEnvironment(); switch (env) { case 'DEV': _baseUrl = AppKeys.baseApiUrlDev; _appIdentifier = AppKeys.appIdentifierDev; break; case 'REC': _baseUrl = AppKeys.baseApiUrlRec; _appIdentifier = AppKeys.appIdentifierRec; break; default: // PROD _baseUrl = AppKeys.baseApiUrlProd; _appIdentifier = AppKeys.appIdentifierProd; } debugPrint('GEOSECTOR 🔗 Environnement: $env, API: $_baseUrl'); } // Définir l'ID de session void setSessionId(String? sessionId) { _sessionId = sessionId; } // Obtenir l'environnement actuel (utile pour le débogage) String getCurrentEnvironment() { return _determineEnvironment(); } // Obtenir l'URL API actuelle (utile pour le débogage) String getCurrentApiUrl() { return _baseUrl; } // Obtenir l'identifiant d'application actuel (utile pour le débogage) String getCurrentAppIdentifier() { return _appIdentifier; } // Vérifier la connectivité réseau Future hasInternetConnection() async { // Utiliser le ConnectivityService s'il est disponible if (_connectivityService != null) { return _connectivityService!.isConnected; } // Fallback sur la vérification directe final connectivityResult = await (Connectivity().checkConnectivity()); return connectivityResult != ConnectivityResult.none; } // Met une requête en file d'attente pour envoi ultérieur Future _queueRequest({ required String method, required String path, dynamic data, Map? queryParameters, String? tempId, Map? metadata, }) async { try { if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { await Hive.openBox(AppKeys.pendingRequestsBoxName); } final box = Hive.box(AppKeys.pendingRequestsBoxName); // Vérifier la limite de 1000 requêtes if (box.length >= 1000) { debugPrint('⚠️ Limite de 1000 requêtes atteinte dans la queue'); throw ApiException( 'La file d\'attente est pleine (1000 requêtes maximum). ' 'Veuillez attendre la synchronisation avant d\'effectuer de nouvelles opérations.', ); } final request = PendingRequest( id: _uuid.v4(), method: method, path: path, data: data, queryParams: queryParameters, // Utiliser queryParams au lieu de queryParameters tempId: tempId, metadata: metadata ?? {}, createdAt: DateTime.now(), context: 'api', // Contexte par défaut retryCount: 0, errorMessage: null, ); await box.add(request); debugPrint('📥 Requête mise en file d\'attente: ${request.toLogString()} (${box.length}/1000)'); } catch (e) { debugPrint('❌ Erreur lors de la mise en file d\'attente: $e'); rethrow; } } // Traite toutes les requêtes en attente (FIFO) Future processPendingRequests() async { if (_isProcessingQueue) { debugPrint('⏳ Traitement déjà en cours'); return; } _isProcessingQueue = true; try { if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { debugPrint('📦 Box pending_requests non ouverte'); return; } final box = Hive.box(AppKeys.pendingRequestsBoxName); while (box.isNotEmpty && (_connectivityService?.isConnected ?? true)) { // Récupérer les requêtes triées par date de création (FIFO) final requests = box.values.toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); if (requests.isEmpty) break; final request = requests.first; debugPrint('🚀 Traitement de la requête: ${request.toLogString()}'); try { // Exécuter la requête Response? response; switch (request.method.toUpperCase()) { case 'GET': response = await _dio.get( request.path, queryParameters: request.queryParams, // Utiliser queryParams ); break; case 'POST': response = await _dio.post( request.path, data: request.data, ); break; case 'PUT': response = await _dio.put( request.path, data: request.data, ); break; case 'DELETE': response = await _dio.delete(request.path); break; default: throw Exception('Méthode HTTP non supportée: ${request.method}'); } // Requête réussie - la supprimer de la file await box.delete(request.key); debugPrint('✅ Requête traitée avec succès et supprimée de la file'); // Traiter la réponse si nécessaire (gestion des temp IDs, etc.) if (request.tempId != null) { await _handleTempIdResponse(request.tempId!, response.data); } } catch (e) { debugPrint('❌ Erreur lors du traitement de la requête: $e'); // Vérifier si c'est une erreur de conflit (409) bool isConflict = false; if (e is DioException && e.response?.statusCode == 409) { isConflict = true; debugPrint('⚠️ Conflit détecté (409) - La requête sera marquée comme en conflit'); } // Vérifier si c'est une erreur permanente (4xx sauf 409) bool isPermanentError = false; if (e is DioException && e.response != null) { final statusCode = e.response!.statusCode ?? 0; if (statusCode >= 400 && statusCode < 500 && statusCode != 409) { isPermanentError = true; debugPrint('❌ Erreur permanente (${statusCode}) - La requête sera supprimée'); } } if (isPermanentError) { // Supprimer les requêtes avec erreurs permanentes (sauf conflits) await box.delete(request.key); debugPrint('🗑️ Requête supprimée de la file (erreur permanente)'); // Notifier l'utilisateur si possible // TODO: Implémenter un système de notification des erreurs permanentes } else if (isConflict) { // Marquer la requête comme en conflit final updatedMetadata = Map.from(request.metadata ?? {}); updatedMetadata['hasConflict'] = true; final conflictRequest = request.copyWith( retryCount: request.retryCount + 1, errorMessage: 'CONFLICT: ${e.toString()}', metadata: updatedMetadata, ); await box.put(request.key, conflictRequest); // Passer à la requête suivante sans attendre debugPrint('⏭️ Passage à la requête suivante (conflit à résoudre manuellement)'); continue; } else { // Erreur temporaire - réessayer plus tard final updatedRequest = request.copyWith( retryCount: request.retryCount + 1, errorMessage: e.toString(), ); await box.put(request.key, updatedRequest); // Arrêter le traitement si la connexion est perdue if (!(_connectivityService?.isConnected ?? true)) { debugPrint('📡 Connexion perdue - Arrêt du traitement'); break; } // Limiter le nombre de tentatives if (request.retryCount >= 5) { debugPrint('⚠️ Nombre maximum de tentatives atteint (5) - Passage à la requête suivante'); continue; } // Attendre avant de réessayer (avec backoff exponentiel) final delay = request.getNextRetryDelay(); debugPrint('⏳ Attente de ${delay.inSeconds}s avant la prochaine tentative'); await Future.delayed(delay); } } } if (box.isEmpty) { debugPrint('✅ Toutes les requêtes ont été traitées'); } else { debugPrint('📝 ${box.length} requête(s) restante(s) en file d\'attente'); } } catch (e) { debugPrint('❌ Erreur lors du traitement de la file: $e'); } finally { _isProcessingQueue = false; } } // Gère la réponse pour les entités temporaires Future _handleTempIdResponse(String tempId, dynamic responseData) async { debugPrint('🔄 Mapping tempId: $tempId avec la réponse'); try { // Vérifier si l'API a retourné un temp_id pour confirmation final returnedTempId = responseData['temp_id']; if (returnedTempId != null && returnedTempId != tempId) { debugPrint('⚠️ TempId mismatch: attendu $tempId, reçu $returnedTempId'); return; } // Gérer les messages du chat if (tempId.startsWith('temp_msg_')) { await _handleTempMessageMapping(tempId, responseData); } // Gérer les rooms du chat else if (tempId.startsWith('temp_room_')) { await _handleTempRoomMapping(tempId, responseData); } // Autres types d'entités temporaires peuvent être ajoutés ici } catch (e) { debugPrint('❌ Erreur lors du mapping tempId $tempId: $e'); } } // Gère le mapping des messages temporaires Future _handleTempMessageMapping(String tempId, Map responseData) async { try { // Importer les modèles nécessaires final messagesBoxName = AppKeys.chatMessagesBoxName; if (!Hive.isBoxOpen(messagesBoxName)) { debugPrint('📦 Box $messagesBoxName non ouverte'); return; } // Utiliser un import dynamique pour éviter les dépendances circulaires final messagesBox = Hive.box(messagesBoxName); // Récupérer le message temporaire final tempMessage = messagesBox.get(tempId); if (tempMessage == null) { debugPrint('⚠️ Message temporaire $tempId non trouvé dans Hive'); return; } // Récupérer l'ID réel depuis la réponse final realId = responseData['id']?.toString(); if (realId == null || realId.isEmpty) { debugPrint('⚠️ ID réel non trouvé dans la réponse pour $tempId'); return; } // Créer le message avec l'ID réel et marquer comme synchronisé // Note: On ne peut pas utiliser Message.fromJson ici car ApiService ne connaît pas le modèle // On va donc stocker les données brutes et laisser ChatService faire la conversion final syncedMessageData = Map.from(responseData); syncedMessageData['is_synced'] = true; // Supprimer le temporaire et ajouter le message avec l'ID réel await messagesBox.delete(tempId); await messagesBox.put(realId, syncedMessageData); debugPrint('✅ Message $tempId remplacé par ID réel $realId'); } catch (e) { debugPrint('❌ Erreur mapping message $tempId: $e'); } } // Gère le mapping des rooms temporaires Future _handleTempRoomMapping(String tempId, Map responseData) async { try { final roomsBoxName = AppKeys.chatRoomsBoxName; if (!Hive.isBoxOpen(roomsBoxName)) { debugPrint('📦 Box $roomsBoxName non ouverte'); return; } final roomsBox = Hive.box(roomsBoxName); // Récupérer la room temporaire final tempRoom = roomsBox.get(tempId); if (tempRoom == null) { debugPrint('⚠️ Room temporaire $tempId non trouvée dans Hive'); return; } // Récupérer l'ID réel depuis la réponse final realId = responseData['id']?.toString(); if (realId == null || realId.isEmpty) { debugPrint('⚠️ ID réel non trouvé dans la réponse pour $tempId'); return; } // Créer la room avec l'ID réel et marquer comme synchronisée final syncedRoomData = Map.from(responseData); syncedRoomData['is_synced'] = true; // Supprimer le temporaire et ajouter la room avec l'ID réel await roomsBox.delete(tempId); await roomsBox.put(realId, syncedRoomData); debugPrint('✅ Room $tempId remplacée par ID réel $realId'); // Mettre à jour les messages qui référencent cette room temporaire await _updateMessagesWithNewRoomId(tempId, realId); } catch (e) { debugPrint('❌ Erreur mapping room $tempId: $e'); } } // Met à jour les messages qui référencent une room temporaire Future _updateMessagesWithNewRoomId(String tempRoomId, String realRoomId) async { try { final messagesBoxName = AppKeys.chatMessagesBoxName; if (!Hive.isBoxOpen(messagesBoxName)) { return; } final messagesBox = Hive.box(messagesBoxName); int updatedCount = 0; // Parcourir tous les messages pour mettre à jour le roomId for (final key in messagesBox.keys) { final message = messagesBox.get(key); if (message != null && message is Map) { final messageData = Map.from(message); if (messageData['roomId'] == tempRoomId || messageData['room_id'] == tempRoomId) { messageData['roomId'] = realRoomId; messageData['room_id'] = realRoomId; await messagesBox.put(key, messageData); updatedCount++; } } } if (updatedCount > 0) { debugPrint('✅ $updatedCount messages mis à jour avec le nouveau roomId $realRoomId'); } } catch (e) { debugPrint('❌ Erreur lors de la mise à jour des messages: $e'); } } // Méthode POST générique Future post(String path, {dynamic data, String? tempId}) async { // Vérifier la connectivité if (!await hasInternetConnection()) { // Mettre en file d'attente await _queueRequest( method: 'POST', path: path, data: data, tempId: tempId, ); // Retourner une réponse vide pour éviter les erreurs return Response( requestOptions: RequestOptions(path: path), statusCode: 202, // Accepté mais pas traité data: {'queued': true, 'tempId': tempId}, ); } try { // Ajouter le tempId au body si présent final requestData = Map.from(data ?? {}); if (tempId != null) { requestData['temp_id'] = tempId; } return await _dio.post(path, data: requestData); } on DioException catch (e) { // Si erreur réseau, mettre en file d'attente if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.connectionError || e.type == DioExceptionType.unknown) { await _queueRequest( method: 'POST', path: path, data: data, tempId: tempId, ); return Response( requestOptions: RequestOptions(path: path), statusCode: 202, data: {'queued': true, 'tempId': tempId}, ); } throw ApiException.fromDioException(e); } catch (e) { if (e is ApiException) rethrow; throw ApiException('Erreur inattendue lors de la requête POST', originalError: e); } } // Méthode GET générique Future get(String path, {Map? queryParameters}) async { // Vérifier la connectivité if (!await hasInternetConnection()) { // Mettre en file d'attente await _queueRequest( method: 'GET', path: path, queryParameters: queryParameters, ); // Retourner une réponse vide return Response( requestOptions: RequestOptions(path: path), statusCode: 202, data: {'queued': true}, ); } try { return await _dio.get(path, queryParameters: queryParameters); } on DioException catch (e) { // Si erreur réseau, mettre en file d'attente if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.connectionError || e.type == DioExceptionType.unknown) { await _queueRequest( method: 'GET', path: path, queryParameters: queryParameters, ); return Response( requestOptions: RequestOptions(path: path), statusCode: 202, data: {'queued': true}, ); } throw ApiException.fromDioException(e); } catch (e) { if (e is ApiException) rethrow; throw ApiException('Erreur inattendue lors de la requête GET', originalError: e); } } // Méthode PUT générique Future put(String path, {dynamic data, String? tempId}) async { // Vérifier la connectivité if (!await hasInternetConnection()) { // Mettre en file d'attente await _queueRequest( method: 'PUT', path: path, data: data, tempId: tempId, ); // Retourner une réponse vide return Response( requestOptions: RequestOptions(path: path), statusCode: 202, data: {'queued': true, 'tempId': tempId}, ); } try { // Ajouter le tempId au body si présent final requestData = Map.from(data ?? {}); if (tempId != null) { requestData['temp_id'] = tempId; } return await _dio.put(path, data: requestData); } on DioException catch (e) { // Si erreur réseau, mettre en file d'attente if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.connectionError || e.type == DioExceptionType.unknown) { await _queueRequest( method: 'PUT', path: path, data: data, tempId: tempId, ); return Response( requestOptions: RequestOptions(path: path), statusCode: 202, data: {'queued': true, 'tempId': tempId}, ); } throw ApiException.fromDioException(e); } catch (e) { if (e is ApiException) rethrow; throw ApiException('Erreur inattendue lors de la requête PUT', originalError: e); } } // Méthode DELETE générique Future delete(String path, {String? tempId}) async { // Vérifier la connectivité if (!await hasInternetConnection()) { // Mettre en file d'attente await _queueRequest( method: 'DELETE', path: path, tempId: tempId, ); // Retourner une réponse vide return Response( requestOptions: RequestOptions(path: path), statusCode: 202, data: {'queued': true, 'tempId': tempId}, ); } try { return await _dio.delete(path); } on DioException catch (e) { // Si erreur réseau, mettre en file d'attente if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.connectionError || e.type == DioExceptionType.unknown) { await _queueRequest( method: 'DELETE', path: path, tempId: tempId, ); return Response( requestOptions: RequestOptions(path: path), statusCode: 202, data: {'queued': true, 'tempId': tempId}, ); } throw ApiException.fromDioException(e); } catch (e) { if (e is ApiException) rethrow; throw ApiException('Erreur inattendue lors de la requête DELETE', originalError: e); } } // === GESTION DES CONFLITS === // Récupère les requêtes en conflit List getConflictedRequests() { if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { return []; } final box = Hive.box(AppKeys.pendingRequestsBoxName); return box.values .where((request) => request.metadata != null && request.metadata!['hasConflict'] == true) .toList(); } // Compte les requêtes en conflit int getConflictedRequestsCount() { return getConflictedRequests().length; } // Résout un conflit en supprimant la requête Future resolveConflictByDeletion(String requestId) async { if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { await Hive.openBox(AppKeys.pendingRequestsBoxName); } final box = Hive.box(AppKeys.pendingRequestsBoxName); final request = box.values.firstWhere( (r) => r.id == requestId, orElse: () => throw Exception('Requête non trouvée'), ); await box.delete(request.key); debugPrint('🗑️ Conflit résolu par suppression de la requête ${requestId}'); } // Résout un conflit en forçant le réessai Future resolveConflictByRetry(String requestId) async { if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { await Hive.openBox(AppKeys.pendingRequestsBoxName); } final box = Hive.box(AppKeys.pendingRequestsBoxName); final request = box.values.firstWhere( (r) => r.id == requestId, orElse: () => throw Exception('Requête non trouvée'), ); // Retirer le marqueur de conflit final updatedMetadata = Map.from(request.metadata ?? {}); updatedMetadata.remove('hasConflict'); final updatedRequest = request.copyWith( retryCount: 0, // Réinitialiser le compteur errorMessage: null, metadata: updatedMetadata, ); await box.put(request.key, updatedRequest); debugPrint('🔄 Conflit marqué pour réessai: ${requestId}'); // Relancer le traitement si connecté if (_connectivityService?.isConnected ?? false) { processPendingRequests(); } } // === EXPORT DES DONNÉES EN ATTENTE === // Exporte toutes les requêtes en attente en JSON String exportPendingRequestsToJson() { if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { return '[]'; } final box = Hive.box(AppKeys.pendingRequestsBoxName); final requests = box.values.map((request) => { 'id': request.id, 'method': request.method, 'path': request.path, 'data': request.data, 'queryParams': request.queryParams, 'tempId': request.tempId, 'metadata': request.metadata, 'createdAt': request.createdAt.toIso8601String(), 'retryCount': request.retryCount, 'errorMessage': request.errorMessage, 'hasConflict': request.metadata != null ? (request.metadata!['hasConflict'] ?? false) : false, }).toList(); return jsonEncode({ 'exportDate': DateTime.now().toIso8601String(), 'totalRequests': requests.length, 'conflictedRequests': requests.where((r) => r['hasConflict'] == true).length, 'requests': requests, }); } // Importe des requêtes depuis un JSON (fusion avec l'existant) Future importPendingRequestsFromJson(String jsonString) async { try { final data = jsonDecode(jsonString); if (data['requests'] == null || data['requests'] is! List) { throw FormatException('Format JSON invalide'); } if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { await Hive.openBox(AppKeys.pendingRequestsBoxName); } final box = Hive.box(AppKeys.pendingRequestsBoxName); // Vérifier la limite final currentCount = box.length; final importCount = (data['requests'] as List).length; if (currentCount + importCount > 1000) { throw ApiException( 'Import impossible: dépassement de la limite de 1000 requêtes. ' 'Actuellement: $currentCount, À importer: $importCount', ); } // Récupérer les IDs existants pour éviter les doublons final existingIds = box.values.map((r) => r.id).toSet(); int imported = 0; for (final requestData in data['requests']) { final requestId = requestData['id'] as String; // Éviter les doublons if (existingIds.contains(requestId)) { debugPrint('⚠️ Requête ${requestId} déjà présente, ignorée'); continue; } final request = PendingRequest( id: requestId, method: requestData['method'] as String, path: requestData['path'] as String, data: requestData['data'] as Map?, queryParams: requestData['queryParams'] as Map?, tempId: requestData['tempId'] as String?, metadata: Map.from(requestData['metadata'] ?? {}), createdAt: DateTime.parse(requestData['createdAt'] as String), context: requestData['context'] ?? 'api', retryCount: requestData['retryCount'] ?? 0, errorMessage: requestData['errorMessage'] as String?, ); await box.add(request); imported++; } debugPrint('✅ Import terminé: $imported requêtes importées'); return imported; } catch (e) { debugPrint('❌ Erreur lors de l\'import: $e'); rethrow; } } // Obtient des statistiques sur les requêtes en attente Map getPendingRequestsStats() { if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { return { 'total': 0, 'conflicted': 0, 'failed': 0, 'byMethod': {}, 'oldestRequest': null, }; } final box = Hive.box(AppKeys.pendingRequestsBoxName); final requests = box.values.toList(); if (requests.isEmpty) { return { 'total': 0, 'conflicted': 0, 'failed': 0, 'byMethod': {}, 'oldestRequest': null, }; } // Trier par date pour trouver la plus ancienne requests.sort((a, b) => a.createdAt.compareTo(b.createdAt)); // Compter par méthode final byMethod = {}; for (final request in requests) { byMethod[request.method] = (byMethod[request.method] ?? 0) + 1; } return { 'total': requests.length, 'conflicted': requests.where((r) => r.metadata != null && r.metadata!['hasConflict'] == true).length, 'failed': requests.where((r) => r.retryCount >= 5).length, 'byMethod': byMethod, 'oldestRequest': requests.first.createdAt.toIso8601String(), 'newestRequest': requests.last.createdAt.toIso8601String(), }; } // Méthode pour uploader un logo d'amicale Future> uploadLogo(int entiteId, dynamic imageFile) async { try { FormData formData; // Gestion différente selon la plateforme (Web ou Mobile) if (kIsWeb) { // Pour le web, imageFile est un XFile final bytes = await imageFile.readAsBytes(); // Vérification de la taille (5 Mo max) const int maxSize = 5 * 1024 * 1024; if (bytes.length > maxSize) { throw ApiException( 'Le fichier est trop volumineux. Taille maximale: 5 Mo', statusCode: 413 ); } formData = FormData.fromMap({ 'logo': MultipartFile.fromBytes( bytes, filename: imageFile.name, ), }); } else { // Pour mobile, imageFile est un File final fileLength = await imageFile.length(); // Vérification de la taille (5 Mo max) const int maxSize = 5 * 1024 * 1024; if (fileLength > maxSize) { throw ApiException( 'Le fichier est trop volumineux. Taille maximale: 5 Mo', statusCode: 413 ); } formData = FormData.fromMap({ 'logo': await MultipartFile.fromFile( imageFile.path, filename: imageFile.path.split('/').last, ), }); } final response = await _dio.post( '/entites/$entiteId/logo', data: formData, options: Options( headers: { 'Content-Type': 'multipart/form-data', }, ), ); if (response.statusCode == 200 || response.statusCode == 201) { return response.data; } else { throw ApiException('Erreur lors de l\'upload du logo', statusCode: response.statusCode); } } on DioException catch (e) { throw ApiException.fromDioException(e); } catch (e) { if (e is ApiException) rethrow; throw ApiException('Erreur inattendue lors de l\'upload du logo', originalError: e); } } // Authentification avec PHP session Future> login(String username, String password, {required String type}) async { try { final response = await _dio.post(AppKeys.loginEndpoint, data: { 'username': username, 'password': password, 'type': type, }); final data = response.data as Map; final status = data['status'] as String?; // Si le statut n'est pas 'success', créer une exception avec le message de l'API if (status != 'success') { final message = data['message'] as String? ?? 'Erreur de connexion'; throw ApiException(message); } // Si succès, configurer la session if (data.containsKey('session_id')) { final sessionId = data['session_id']; if (sessionId != null) { setSessionId(sessionId); // Collecter et envoyer les informations du device après login réussi debugPrint('📱 Collecte des informations device après login...'); DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) { debugPrint('✅ Informations device collectées et envoyées'); }).catchError((error) { debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error'); // Ne pas bloquer le login si l'envoi des infos device échoue }); } } return data; } on DioException catch (e) { throw ApiException.fromDioException(e); } catch (e) { if (e is ApiException) rethrow; throw ApiException('Erreur inattendue lors de la connexion', originalError: e); } } // === MÉTHODES DE REFRESH DE SESSION === /// Rafraîchit toutes les données de session (pour F5, démarrage) /// Retourne les mêmes données qu'un login normal Future refreshSessionAll() async { try { debugPrint('🔄 Refresh complet de session'); // Vérifier qu'on a bien un token/session if (_sessionId == null) { throw ApiException('Pas de session active pour le refresh'); } final response = await post('/session/refresh/all'); // Traiter la réponse comme un login final data = response.data as Map?; if (data != null && data['status'] == 'success') { // Si nouveau session_id dans la réponse, le mettre à jour if (data.containsKey('session_id')) { final newSessionId = data['session_id']; if (newSessionId != null) { setSessionId(newSessionId); } } // Collecter et envoyer les informations du device après refresh réussi debugPrint('📱 Collecte des informations device après refresh de session...'); DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) { debugPrint('✅ Informations device collectées et envoyées (refresh)'); }).catchError((error) { debugPrint('⚠️ Erreur lors de l\'envoi des infos device (refresh): $error'); // Ne pas bloquer le refresh si l'envoi des infos device échoue }); } return response; } catch (e) { debugPrint('❌ Erreur refresh complet: $e'); rethrow; } } /// Rafraîchit partiellement les données modifiées depuis lastSync /// Ne retourne que les données modifiées (delta) Future refreshSessionPartial(DateTime lastSync) async { try { debugPrint('🔄 Refresh partiel depuis: ${lastSync.toIso8601String()}'); // Vérifier qu'on a bien un token/session if (_sessionId == null) { throw ApiException('Pas de session active pour le refresh'); } final response = await post('/session/refresh/partial', data: { 'last_sync': lastSync.toIso8601String(), }); return response; } catch (e) { debugPrint('❌ Erreur refresh partiel: $e'); rethrow; } } // Déconnexion Future logout() async { try { if (_sessionId != null) { await _dio.post(AppKeys.logoutEndpoint); _sessionId = null; } } catch (e) { // Même en cas d'erreur, on réinitialise la session _sessionId = null; rethrow; } } // Utilisateurs Future> getUsers() async { try { final response = await retry( () => _dio.get('/users'), retryIf: (e) => e is SocketException || e is TimeoutException, ); return (response.data as List).map((json) => UserModel.fromJson(json)).toList(); } catch (e) { // Gérer les erreurs rethrow; } } Future getUserById(int id) async { try { final response = await _dio.get('/users/$id'); return UserModel.fromJson(response.data); } catch (e) { rethrow; } } Future updateUser(UserModel user) async { try { final response = await _dio.put('/users/${user.id}', data: user.toJson()); // Vérifier la structure de la réponse final data = response.data as Map; // Si l'API retourne {status: "success", message: "..."} if (data.containsKey('status') && data['status'] == 'success') { // L'API confirme le succès mais ne retourne pas l'objet user // On retourne l'utilisateur original qui a été envoyé debugPrint('✅ API updateUser success: ${data['message']}'); return user; } // Si l'API retourne directement un UserModel (fallback) return UserModel.fromJson(data); } on DioException catch (e) { throw ApiException.fromDioException(e); } catch (e) { throw ApiException('Erreur inattendue lors de la mise à jour', originalError: e); } } // Appliquer la même logique aux autres méthodes Future createUser(UserModel user) async { try { final response = await _dio.post('/users', data: user.toJson()); return UserModel.fromJson(response.data); } on DioException catch (e) { throw ApiException.fromDioException(e); } catch (e) { throw ApiException('Erreur inattendue lors de la création', originalError: e); } } Future deleteUser(String id) async { try { await _dio.delete('/users/$id'); } catch (e) { rethrow; } } // Synchronisation en batch Future> syncData({ List? users, }) async { try { final Map payload = { if (users != null) 'users': users.map((u) => u.toJson()).toList(), }; final response = await _dio.post('/sync', data: payload); return response.data; } catch (e) { rethrow; } } // Export Excel d'une opération Future downloadOperationExcel(int operationId, String fileName) async { try { debugPrint('📊 Téléchargement Excel pour opération $operationId'); final response = await _dio.get( '/operations/$operationId/export/excel', options: Options( responseType: ResponseType.bytes, // Important pour les fichiers binaires headers: { 'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }, ), ); if (response.statusCode == 200) { debugPrint('✅ Fichier Excel reçu (${response.data.length} bytes)'); if (kIsWeb) { // Pour le web : déclencher le téléchargement via le navigateur _downloadFileWeb(response.data, fileName); } else { // Pour mobile : sauvegarder dans le dossier de téléchargements await _downloadFileMobile(response.data, fileName); } debugPrint('✅ Export Excel terminé: $fileName'); } else { throw ApiException('Erreur lors du téléchargement: ${response.statusCode}'); } } on DioException catch (e) { throw ApiException.fromDioException(e); } catch (e) { if (e is ApiException) rethrow; throw ApiException('Erreur inattendue lors de l\'export Excel', originalError: e); } } // Téléchargement pour le web void _downloadFileWeb(List bytes, String fileName) { final blob = html.Blob([bytes]); final url = html.Url.createObjectUrlFromBlob(blob); html.AnchorElement(href: url) ..setAttribute('download', fileName) ..click(); html.Url.revokeObjectUrl(url); debugPrint('🌐 Téléchargement web déclenché: $fileName'); } // Téléchargement pour mobile Future _downloadFileMobile(List bytes, String fileName) async { try { // Pour mobile, on pourrait utiliser path_provider pour obtenir le dossier de téléchargements // et file_picker ou similar pour sauvegarder le fichier // Pour l'instant, on lance juste une exception informative throw const ApiException('Téléchargement mobile non implémenté. Utilisez la version web.'); } catch (e) { rethrow; } } // Méthode de nettoyage pour les tests static void reset() { _instance?._connectivityService?.removeListener(_instance!._onConnectivityChanged); _instance?._connectivityService?.dispose(); _instance = null; } // Dispose pour nettoyer les ressources void dispose() { _connectivityService?.removeListener(_onConnectivityChanged); _connectivityService?.dispose(); } }