feat: synchronisation mode deconnecte fin chat et stats

This commit is contained in:
2025-08-31 18:21:20 +02:00
parent f5bef999df
commit 96af94ad13
129 changed files with 125731 additions and 110375 deletions

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
@@ -8,6 +9,10 @@ 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';
class ApiService {
static ApiService? _instance;
@@ -19,6 +24,11 @@ class ApiService {
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;
@@ -70,6 +80,60 @@ class ApiService {
));
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
@@ -138,15 +202,408 @@ class ApiService {
// 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.contains(ConnectivityResult.none) == false;
}
// 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}) async {
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 {
return await _dio.post(path, data: data);
// 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;
@@ -156,9 +613,40 @@ class ApiService {
// 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;
@@ -167,10 +655,49 @@ class ApiService {
}
// Méthode PUT générique
Future<Response> put(String path, {dynamic data}) async {
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 {
return await _dio.put(path, data: data);
// 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;
@@ -179,10 +706,41 @@ class ApiService {
}
// Méthode DELETE générique
Future<Response> delete(String path) async {
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;
@@ -190,6 +748,211 @@ class ApiService {
}
}
// === 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 {
@@ -458,6 +1221,14 @@ class ApiService {
// 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();
}
}

View File

@@ -0,0 +1,131 @@
import 'package:geosector_app/chat/chat_module.dart';
import 'package:geosector_app/chat/services/chat_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
/// Service singleton pour gérer le cycle de vie du module chat
/// Initialise le chat une seule fois au login et maintient les syncs en arrière-plan
class ChatManager {
static ChatManager? _instance;
static ChatManager get instance => _instance ??= ChatManager._();
ChatManager._();
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
bool _isPaused = false;
bool get isPaused => _isPaused;
/// Initialiser le chat (appelé après login réussi)
/// Cette méthode est idempotente - peut être appelée plusieurs fois sans effet
Future<void> initializeChat() async {
if (_isInitialized) {
print('⚠️ Chat déjà initialisé - ignoré');
return;
}
try {
// Récupérer les informations de l'utilisateur connecté
final currentUser = CurrentUserService.instance;
final apiService = ApiService.instance;
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
if (currentUser.currentUser == null) {
print('❌ Impossible d\'initialiser le chat - utilisateur non connecté');
return;
}
print('🔄 Initialisation du chat pour ${currentUser.userName}...');
// Initialiser le module chat
await ChatModule.init(
apiUrl: apiService.baseUrl,
userId: currentUser.currentUser!.id,
userName: currentUser.userName ?? currentUser.userEmail ?? 'Utilisateur',
userRole: currentUser.currentUser!.role,
userEntite: currentUser.fkEntite ?? currentAmicale?.id,
authToken: currentUser.sessionId,
);
_isInitialized = true;
print('✅ Chat initialisé avec succès - syncs démarrées toutes les 15 secondes');
} catch (e) {
print('❌ Erreur initialisation chat: $e');
// Ne pas propager l'erreur pour ne pas bloquer l'app
// Le chat sera simplement indisponible
_isInitialized = false;
}
}
/// Réinitialiser le chat (utile après changement d'amicale ou reconnexion)
Future<void> reinitialize() async {
print('🔄 Réinitialisation du chat...');
dispose();
await Future.delayed(const Duration(milliseconds: 100));
await initializeChat();
}
/// Arrêter le chat (appelé au logout ou fermeture app)
void dispose() {
if (_isInitialized) {
try {
// Nettoyer le module chat ET le service
ChatModule.cleanup(); // Reset le flag _isInitialized dans ChatModule
_isInitialized = false;
_isPaused = false;
print('🛑 Chat arrêté - syncs stoppées et module réinitialisé');
} catch (e) {
print('⚠️ Erreur lors de l\'arrêt du chat: $e');
}
}
}
/// Mettre en pause les synchronisations (app en arrière-plan)
void pauseSyncs() {
if (_isInitialized && !_isPaused) {
try {
ChatService.instance.pauseSyncs();
_isPaused = true;
print('⏸️ Syncs chat mises en pause');
} catch (e) {
print('⚠️ Erreur lors de la pause du chat: $e');
}
}
}
/// Reprendre les synchronisations (app au premier plan)
void resumeSyncs() {
if (_isInitialized && _isPaused) {
try {
ChatService.instance.resumeSyncs();
_isPaused = false;
print('▶️ Syncs chat reprises');
} catch (e) {
print('⚠️ Erreur lors de la reprise du chat: $e');
}
}
}
/// Vérifier si le chat est prêt à être utilisé
bool get isReady {
if (!_isInitialized) return false;
// Vérifier que l'utilisateur est toujours connecté
final currentUser = CurrentUserService.instance;
if (currentUser.currentUser == null) {
print('⚠️ Chat initialisé mais utilisateur déconnecté');
dispose();
return false;
}
// Ne pas considérer comme prêt si en pause
if (_isPaused) {
print('⚠️ Chat en pause');
return false;
}
return true;
}
}

View File

@@ -33,13 +33,6 @@ class DataLoadingService extends ChangeNotifier {
_progressCallback = callback;
}
// Mettre à jour l'état du chargement
void _updateLoadingState(LoadingState newState) {
_loadingState = newState;
notifyListeners();
_progressCallback?.call(newState);
}
// === GETTERS POUR LES BOXES ===
Box<OperationModel> get _operationBox =>
Hive.box<OperationModel>(AppKeys.operationsBoxName);
@@ -54,7 +47,6 @@ class DataLoadingService extends ChangeNotifier {
Box<AmicaleModel> get _amicaleBox =>
Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
// Chat boxes removed - handled by new chat module
Box get _settingsBox => Hive.box(AppKeys.settingsBoxName);
/// Traite toutes les données reçues de l'API lors du login
/// Les boxes sont déjà propres, on charge juste les données

View File

@@ -8,7 +8,9 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
import 'package:geosector_app/core/data/models/region_model.dart';
// Chat adapters removed - handled by new chat module
import 'package:geosector_app/core/data/models/pending_request.dart';
import 'package:geosector_app/chat/models/room.dart';
import 'package:geosector_app/chat/models/message.dart';
class HiveAdapters {
/// Enregistre tous les TypeAdapters nécessaires
@@ -42,7 +44,17 @@ class HiveAdapters {
Hive.registerAdapter(AmicaleModelAdapter());
}
// Chat adapters are now handled by the chat module itself
// TypeIds 50-60 are reserved for chat module
// Chat adapters - TypeIds 50-51
if (!Hive.isAdapterRegistered(50)) {
Hive.registerAdapter(RoomAdapter());
}
if (!Hive.isAdapterRegistered(51)) {
Hive.registerAdapter(MessageAdapter());
}
// Queue offline adapter - TypeId 100
if (!Hive.isAdapterRegistered(100)) {
Hive.registerAdapter(PendingRequestAdapter());
}
}
}

View File

@@ -15,7 +15,9 @@ import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
// Chat imports removed - using new simplified chat module
import 'package:geosector_app/core/data/models/pending_request.dart';
import 'package:geosector_app/chat/models/room.dart';
import 'package:geosector_app/chat/models/message.dart';
/// Service singleton centralisé pour la gestion complète des Box Hive
/// Utilisé par main.dart pour l'initialisation et par logout pour le nettoyage
@@ -34,7 +36,13 @@ class HiveService {
HiveBoxConfig<PassageModel>(AppKeys.passagesBoxName, 'PassageModel'),
HiveBoxConfig<MembreModel>(AppKeys.membresBoxName, 'MembreModel'),
HiveBoxConfig<UserSectorModel>(AppKeys.userSectorBoxName, 'UserSectorModel'),
// Chat boxes removed - handled by new chat module
// Chat boxes
HiveBoxConfig<Room>(AppKeys.chatRoomsBoxName, 'Room'),
HiveBoxConfig<Message>(AppKeys.chatMessagesBoxName, 'Message'),
// Queue offline boxes
HiveBoxConfig<PendingRequest>(AppKeys.pendingRequestsBoxName, 'PendingRequest'),
HiveBoxConfig<dynamic>(AppKeys.tempEntitiesBoxName, 'TempEntities'),
// Dynamic boxes
HiveBoxConfig<dynamic>(AppKeys.settingsBoxName, 'Settings'),
HiveBoxConfig<dynamic>(AppKeys.regionsBoxName, 'Regions'),
];
@@ -149,6 +157,16 @@ class HiveService {
try {
debugPrint('💥 Destruction complète des données Hive...');
// PROTECTION CRITIQUE : Vérifier la box pending_requests
if (Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
final pendingBox = Hive.box(AppKeys.pendingRequestsBoxName);
if (pendingBox.isNotEmpty) {
debugPrint('⚠️ ATTENTION: ${pendingBox.length} requêtes en attente trouvées dans pending_requests');
debugPrint('⚠️ Cette box NE SERA PAS supprimée pour préserver les données');
// On ne supprime PAS cette box si elle contient des données
}
}
// 1. Fermer toutes les Box ouvertes
await _closeAllOpenBoxes();
@@ -333,6 +351,17 @@ class HiveService {
for (final config in _boxConfigs) {
try {
// PROTECTION : Ne pas supprimer pending_requests si elle contient des données
if (config.name == AppKeys.pendingRequestsBoxName) {
if (Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
final box = Hive.box(AppKeys.pendingRequestsBoxName);
if (box.isNotEmpty) {
debugPrint('⏭️ Box ${config.name} ignorée (contient ${box.length} requêtes en attente)');
continue;
}
}
}
await Hive.deleteBoxFromDisk(config.name);
debugPrint('🗑️ Box fallback ${config.name} supprimée');
} catch (e) {
@@ -401,7 +430,19 @@ class HiveService {
case 'UserSectorModel':
await Hive.openBox<UserSectorModel>(config.name);
break;
// Chat boxes removed - handled by new chat module
case 'Room':
await Hive.openBox<Room>(config.name);
break;
case 'Message':
await Hive.openBox<Message>(config.name);
break;
case 'PendingRequest':
await Hive.openBox<PendingRequest>(config.name);
break;
case 'TempEntities':
// Box dynamique pour stocker les entités temporaires
await Hive.openBox(config.name);
break;
default:
// Pour Settings, Regions, etc.
await Hive.openBox(config.name);
@@ -426,7 +467,61 @@ class HiveService {
Future<void> _clearSingleBox(String boxName) async {
try {
if (Hive.isBoxOpen(boxName)) {
await Hive.box(boxName).clear();
// Récupérer la configuration pour connaître le type
final config = _boxConfigs.firstWhere(
(c) => c.name == boxName,
orElse: () => HiveBoxConfig(boxName, 'dynamic'),
);
// Utiliser la box typée selon le modèle
switch (config.type) {
case 'UserModel':
await Hive.box<UserModel>(boxName).clear();
break;
case 'AmicaleModel':
await Hive.box<AmicaleModel>(boxName).clear();
break;
case 'ClientModel':
await Hive.box<ClientModel>(boxName).clear();
break;
case 'OperationModel':
await Hive.box<OperationModel>(boxName).clear();
break;
case 'SectorModel':
await Hive.box<SectorModel>(boxName).clear();
break;
case 'PassageModel':
await Hive.box<PassageModel>(boxName).clear();
break;
case 'MembreModel':
await Hive.box<MembreModel>(boxName).clear();
break;
case 'UserSectorModel':
await Hive.box<UserSectorModel>(boxName).clear();
break;
case 'Room':
await Hive.box<Room>(boxName).clear();
break;
case 'Message':
await Hive.box<Message>(boxName).clear();
break;
case 'PendingRequest':
// ATTENTION : Ne jamais vider pending_requests si elle contient des données critiques
final pendingBox = Hive.box<PendingRequest>(boxName);
if (pendingBox.isNotEmpty) {
debugPrint('⚠️ ATTENTION: Box $boxName contient ${pendingBox.length} requêtes - Vidage ignoré');
return; // Ne pas vider cette box
}
await pendingBox.clear();
break;
case 'TempEntities':
await Hive.box(boxName).clear();
break;
default:
// Pour les box non typées (settings, regions, etc.)
await Hive.box(boxName).clear();
break;
}
debugPrint('🧹 Box $boxName vidée');
} else {
debugPrint(' Box $boxName n\'est pas ouverte, impossible de la vider');

View File

@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/pending_request.dart';
/// Service pour gérer les entités temporaires créées en mode offline
/// Ces entités ont des IDs temporaires (temp_xxx) en attendant la synchronisation
class TempEntityService {
static TempEntityService? _instance;
static TempEntityService get instance {
_instance ??= TempEntityService._internal();
return _instance!;
}
TempEntityService._internal();
/// Vérifie si un ID est temporaire (créé offline)
/// Les IDs temporaires sont des entiers négatifs
static bool isTemporaryId(dynamic id) {
if (id == null) return false;
if (id is int) {
return id < 0;
}
// Pour compatibilité avec d'autres types d'IDs
return id.toString().startsWith('temp_');
}
/// Vérifie si une entité avec cet ID temporaire est en attente de création
Future<bool> isEntityPendingCreation(dynamic tempId) async {
try {
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
return false;
}
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
// Convertir l'ID en string pour la comparaison
final tempIdStr = tempId.toString();
// Rechercher une requête POST avec ce tempId
for (var request in box.values) {
if (request.tempId == tempIdStr &&
request.method.toUpperCase() == 'POST') {
return true;
}
}
return false;
} catch (e) {
debugPrint('Erreur lors de la vérification de l\'entité temporaire: $e');
return false;
}
}
/// Obtient le nombre d'entités temporaires en attente
int getTemporaryEntitiesCount() {
try {
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
return 0;
}
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
// Compter les requêtes POST avec tempId (créations)
return box.values
.where((request) =>
request.tempId != null &&
request.method.toUpperCase() == 'POST')
.length;
} catch (e) {
debugPrint('Erreur lors du comptage des entités temporaires: $e');
return 0;
}
}
/// Vérifie si une modification est autorisée pour cette entité
Future<bool> canModifyEntity(dynamic entityId) async {
// Si ce n'est pas un ID temporaire, la modification est autorisée
if (!isTemporaryId(entityId)) {
return true;
}
// Si c'est un ID temporaire, vérifier s'il est en attente
final isPending = await isEntityPendingCreation(entityId);
// On peut modifier seulement si l'entité n'est PAS en attente
return !isPending;
}
/// Affiche un message d'erreur pour une entité en attente de synchronisation
static void showPendingSyncError(BuildContext context, {String? entityType}) {
final entity = entityType ?? 'élément';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.sync_problem, color: Colors.white),
const SizedBox(width: 12),
Expanded(
child: Text(
'Ce $entity est en attente de synchronisation.\n'
'Reconnectez-vous à Internet pour pouvoir le modifier.',
style: const TextStyle(color: Colors.white),
),
),
],
),
backgroundColor: Colors.orange.shade700,
duration: const Duration(seconds: 5),
behavior: SnackBarBehavior.floating,
action: SnackBarAction(
label: 'Compris',
textColor: Colors.white,
onPressed: () {},
),
),
);
}
/// Affiche une dialog explicative pour les entités en attente
static Future<void> showPendingSyncDialog(
BuildContext context, {
String? entityType,
VoidCallback? onRetry,
}) async {
final entity = entityType ?? 'élément';
await showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertDialog(
icon: const Icon(Icons.sync_problem, color: Colors.orange, size: 48),
title: Text('$entity en attente'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ce $entity a été créé hors ligne et est en attente de synchronisation avec le serveur.',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.orange),
SizedBox(width: 8),
Text(
'Que faire ?',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
],
),
const SizedBox(height: 8),
const Text(
'• Vérifiez votre connexion Internet',
style: TextStyle(fontSize: 13),
),
const Text(
'• Attendez que la synchronisation se termine',
style: TextStyle(fontSize: 13),
),
Text(
'• Le $entity sera modifiable après synchronisation',
style: const TextStyle(fontSize: 13),
),
],
),
),
const SizedBox(height: 16),
// Afficher le statut de connexion
ValueListenableBuilder<Box<PendingRequest>>(
valueListenable: Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName).listenable(),
builder: (context, box, child) {
final count = box.length;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
const Icon(Icons.pending_actions, size: 16),
const SizedBox(width: 8),
Text(
'$count requête${count > 1 ? 's' : ''} en attente',
style: const TextStyle(fontSize: 13),
),
],
),
);
},
),
],
),
actions: [
if (onRetry != null)
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
onRetry();
},
child: const Text('Réessayer'),
),
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
child: const Text('J\'ai compris'),
),
],
),
);
}
/// Génère un ID temporaire unique (entier négatif)
static int generateTempId() {
// Utiliser un timestamp négatif pour garantir l'unicité
// et éviter les conflits avec les IDs réels (positifs)
final timestamp = DateTime.now().millisecondsSinceEpoch;
// Ajouter un petit random pour éviter les collisions
final random = (timestamp % 1000);
// Retourner un nombre négatif
return -(timestamp + random);
}
/// Génère un ID temporaire sous forme de string (pour tempId dans PendingRequest)
static String generateTempIdString() {
final tempId = generateTempId();
return tempId.toString();
}
/// Extrait l'ID réel d'une réponse API après synchronisation
/// Utilisé pour mapper les IDs temporaires aux IDs réels
static dynamic extractRealId(Map<String, dynamic> response) {
// L'API peut retourner l'ID dans différents champs
return response['id'] ??
response['data']?['id'] ??
response['result']?['id'];
}
}