- Mise à jour VERSION vers 3.3.4 - Optimisations et révisions architecture API (deploy-api.sh, scripts de migration) - Ajout documentation Stripe Tap to Pay complète - Migration vers polices Inter Variable pour Flutter - Optimisations build Android et nettoyage fichiers temporaires - Amélioration système de déploiement avec gestion backups - Ajout scripts CRON et migrations base de données 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1310 lines
43 KiB
Dart
Executable File
1310 lines
43 KiB
Dart
Executable File
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<void> 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<String, String>.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<void> _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<PendingRequest>(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<T>(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<bool> 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<void> _queueRequest({
|
|
required String method,
|
|
required String path,
|
|
dynamic data,
|
|
Map<String, dynamic>? queryParameters,
|
|
String? tempId,
|
|
Map<String, dynamic>? metadata,
|
|
}) async {
|
|
try {
|
|
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
|
await Hive.openBox<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
|
}
|
|
|
|
final box = Hive.box<PendingRequest>(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<void> 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<PendingRequest>(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<String, dynamic>.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<void> _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<void> _handleTempMessageMapping(String tempId, Map<String, dynamic> 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<String, dynamic>.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<void> _handleTempRoomMapping(String tempId, Map<String, dynamic> 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<String, dynamic>.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<void> _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<String, dynamic>.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<Response> 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<String, dynamic>.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<Response> get(String path, {Map<String, dynamic>? 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<Response> 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<String, dynamic>.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<Response> 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<PendingRequest> getConflictedRequests() {
|
|
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
|
return [];
|
|
}
|
|
|
|
final box = Hive.box<PendingRequest>(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<void> resolveConflictByDeletion(String requestId) async {
|
|
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
|
await Hive.openBox<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
|
}
|
|
|
|
final box = Hive.box<PendingRequest>(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<void> resolveConflictByRetry(String requestId) async {
|
|
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
|
await Hive.openBox<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
|
}
|
|
|
|
final box = Hive.box<PendingRequest>(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<String, dynamic>.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<PendingRequest>(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<int> 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<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
|
}
|
|
|
|
final box = Hive.box<PendingRequest>(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<String, dynamic>?,
|
|
queryParams: requestData['queryParams'] as Map<String, dynamic>?,
|
|
tempId: requestData['tempId'] as String?,
|
|
metadata: Map<String, dynamic>.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<String, dynamic> getPendingRequestsStats() {
|
|
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
|
return {
|
|
'total': 0,
|
|
'conflicted': 0,
|
|
'failed': 0,
|
|
'byMethod': {},
|
|
'oldestRequest': null,
|
|
};
|
|
}
|
|
|
|
final box = Hive.box<PendingRequest>(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 = <String, int>{};
|
|
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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String, dynamic>;
|
|
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<Response> 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<String, dynamic>?;
|
|
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<Response> 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<void> 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<List<UserModel>> 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<UserModel> getUserById(int id) async {
|
|
try {
|
|
final response = await _dio.get('/users/$id');
|
|
return UserModel.fromJson(response.data);
|
|
} catch (e) {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<UserModel> 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<String, dynamic>;
|
|
|
|
// 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<UserModel> 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<void> deleteUser(String id) async {
|
|
try {
|
|
await _dio.delete('/users/$id');
|
|
} catch (e) {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
// Synchronisation en batch
|
|
Future<Map<String, dynamic>> syncData({
|
|
List<UserModel>? users,
|
|
}) async {
|
|
try {
|
|
final Map<String, dynamic> 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<void> 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<int> 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<void> _downloadFileMobile(List<int> 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();
|
|
}
|
|
}
|