import 'dart:async'; import 'package:dio/dio.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; import '../models/room.dart'; import '../models/message.dart'; import 'chat_config_loader.dart'; import 'chat_info_service.dart'; import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/core/services/api_service.dart'; import 'package:geosector_app/app.dart'; // Pour accéder à connectivityService /// Service de chat avec règles métier configurables via YAML /// Les permissions sont définies dans chat_config.yaml class ChatService { static ChatService? _instance; static ChatService get instance => _instance!; late final Dio _dio; late final Box _roomsBox; late final Box _messagesBox; late int _currentUserId; late String _currentUserName; late int _currentUserRole; late int? _currentUserEntite; Timer? _syncTimer; DateTime? _lastSyncTimestamp; DateTime? _lastFullSync; static const Duration _syncInterval = Duration(seconds: 15); // Changé à 15 secondes comme suggéré par l'API static const Duration _fullSyncInterval = Duration(minutes: 5); /// Initialisation avec gestion des rôles et configuration YAML static Future init({ required String apiUrl, required int userId, required String userName, required int userRole, int? userEntite, String? authToken, }) async { _instance = ChatService._(); // Charger la configuration depuis le YAML await ChatConfigLoader.instance.loadConfig(); // Les boxes sont déjà ouvertes par HiveService dans splash_page // On vérifie juste qu'elles sont disponibles et on les récupère if (!Hive.isBoxOpen(AppKeys.chatRoomsBoxName)) { throw Exception('Chat rooms box not open. Please ensure HiveService is initialized.'); } if (!Hive.isBoxOpen(AppKeys.chatMessagesBoxName)) { throw Exception('Chat messages box not open. Please ensure HiveService is initialized.'); } // Récupérer les boxes déjà ouvertes _instance!._roomsBox = Hive.box(AppKeys.chatRoomsBoxName); _instance!._messagesBox = Hive.box(AppKeys.chatMessagesBoxName); // Configurer l'utilisateur _instance!._currentUserId = userId; _instance!._currentUserName = userName; _instance!._currentUserRole = userRole; _instance!._currentUserEntite = userEntite; // Configurer Dio _instance!._dio = Dio(BaseOptions( baseUrl: apiUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), headers: authToken != null ? {'Authorization': 'Bearer $authToken'} : {}, )); // Charger le dernier timestamp de sync depuis Hive await _instance!._loadSyncTimestamp(); // Faire la sync initiale complète au login await _instance!.getRooms(forceFullSync: true); print('✅ Sync initiale complète effectuée au login'); // Démarrer la synchronisation incrémentale périodique _instance!._startSync(); } ChatService._(); /// Obtenir les destinataires possibles selon le rôle Future>> getPossibleRecipients({String? search}) async { try { // L'API utilise le token pour identifier l'utilisateur et son rôle String endpoint = '/chat/recipients'; final params = {}; if (search != null && search.isNotEmpty) { params['search'] = search; } final response = await _dio.get(endpoint, queryParameters: params); // Gérer différents formats de réponse if (response.data is List) { return List>.from(response.data); } else if (response.data is Map && response.data['recipients'] != null) { return List>.from(response.data['recipients']); } else if (response.data is Map && response.data['data'] != null) { return List>.from(response.data['data']); } else { print('⚠️ Format inattendu pour /chat/recipients: ${response.data.runtimeType}'); return []; } } catch (e) { print('⚠️ Erreur getPossibleRecipients: $e'); // Fallback sur logique locale selon le rôle return _getLocalRecipients(); } } /// Logique locale de récupération des destinataires List> _getLocalRecipients() { // Cette méthode devrait accéder à la box membres de l'app principale // Pour l'instant on retourne une liste vide return []; } /// Vérifier si l'utilisateur peut créer une conversation avec un destinataire bool canCreateConversationWith(int recipientRole, {int? recipientEntite}) { return ChatConfigLoader.instance.canSendMessageTo( senderRole: _currentUserRole, recipientRole: recipientRole, senderEntite: _currentUserEntite, recipientEntite: recipientEntite, ); } /// Obtenir les rooms avec synchronisation incrémentale Future> getRooms({bool forceFullSync = false}) async { // Vérifier la connectivité if (!connectivityService.isConnected) { print('📵 Pas de connexion réseau - utilisation du cache'); return _roomsBox.values.toList() ..sort((a, b) => (b.lastMessageAt ?? b.createdAt) .compareTo(a.lastMessageAt ?? a.createdAt)); } try { // Déterminer si on fait une sync complète ou incrémentale final now = DateTime.now(); final needsFullSync = forceFullSync || _lastFullSync == null || now.difference(_lastFullSync!).compareTo(_fullSyncInterval) > 0; Response response; if (needsFullSync || _lastSyncTimestamp == null) { // Synchronisation complète print('🔄 Synchronisation complète des rooms...'); response = await _dio.get('/chat/rooms'); _lastFullSync = now; } else { // Synchronisation incrémentale final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String(); print('🔄 Synchronisation incrémentale depuis $isoTimestamp'); response = await _dio.get('/chat/rooms', queryParameters: { 'updated_after': isoTimestamp, }); } // Extraire le timestamp de synchronisation fourni par l'API if (response.data is Map && response.data['sync_timestamp'] != null) { _lastSyncTimestamp = DateTime.parse(response.data['sync_timestamp']); print('⏰ Timestamp de sync reçu de l\'API: $_lastSyncTimestamp'); // Sauvegarder le timestamp pour la prochaine session await _saveSyncTimestamp(); } else { // L'API doit toujours retourner un sync_timestamp print('⚠️ Attention: L\'API n\'a pas retourné de sync_timestamp'); // On utilise le timestamp actuel comme fallback mais ce n'est pas idéal _lastSyncTimestamp = now; } // Vérifier s'il y a des changements (pour sync incrémentale) if (!needsFullSync && response.data is Map && response.data['has_changes'] == false) { print('✅ Aucun changement depuis la dernière sync'); return _roomsBox.values.toList() ..sort((a, b) => (b.lastMessageAt ?? b.createdAt) .compareTo(a.lastMessageAt ?? a.createdAt)); } // Gérer différents formats de réponse API List roomsData; if (response.data is Map) { if (response.data['rooms'] != null) { roomsData = response.data['rooms'] as List; final hasChanges = response.data['has_changes'] ?? true; print('✅ Réponse API: ${roomsData.length} rooms, has_changes: $hasChanges'); } else if (response.data['data'] != null) { roomsData = response.data['data'] as List; } else { roomsData = []; } } else if (response.data is List) { roomsData = response.data as List; } else { roomsData = []; } // Parser les rooms final rooms = []; final deletedRoomIds = []; for (final json in roomsData) { try { // Vérifier si la room est marquée comme supprimée if (json['deleted'] == true) { deletedRoomIds.add(json['id']); continue; } final room = Room.fromJson(json); rooms.add(room); } catch (e) { print('❌ Erreur parsing room: $e'); } } // Appliquer les modifications à Hive if (needsFullSync) { // Sync complète : remplacer tout mais préserver certaines données locales final existingRooms = Map.fromEntries( _roomsBox.values.map((r) => MapEntry(r.id, r)) ); await _roomsBox.clear(); for (final room in rooms) { final existingRoom = existingRooms[room.id]; final roomToSave = Room( id: room.id, title: room.title, type: room.type, createdAt: room.createdAt, lastMessage: room.lastMessage, lastMessageAt: room.lastMessageAt, unreadCount: room.unreadCount, recentMessages: room.recentMessages, updatedAt: room.updatedAt, // Préserver createdBy existant si la nouvelle room n'en a pas createdBy: room.createdBy ?? existingRoom?.createdBy, ); await _roomsBox.put(roomToSave.id, roomToSave); // Traiter les messages récents de la room if (room.recentMessages != null && room.recentMessages!.isNotEmpty) { for (final msgData in room.recentMessages!) { try { final message = Message.fromJson(msgData, _currentUserId, roomId: room.id); // Sauvegarder uniquement si le message n'existe pas déjà if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) { await _messagesBox.put(message.id, message); print('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}'); } else if (message.id.isEmpty) { print('⚠️ Message avec ID vide ignoré'); } } catch (e) { print('⚠️ Erreur lors du traitement d\'un message récent: $e'); } } } } print('💾 Sync complète: ${rooms.length} rooms sauvegardées'); } else { // Sync incrémentale : mettre à jour uniquement les changements for (final room in rooms) { // Préserver le createdBy existant si non fourni par l'API final existingRoom = _roomsBox.get(room.id); final roomToSave = Room( id: room.id, title: room.title, type: room.type, createdAt: room.createdAt, lastMessage: room.lastMessage, lastMessageAt: room.lastMessageAt, unreadCount: room.unreadCount, recentMessages: room.recentMessages, updatedAt: room.updatedAt, // Préserver createdBy existant si la nouvelle room n'en a pas createdBy: room.createdBy ?? existingRoom?.createdBy, ); print('💾 Sauvegarde room ${roomToSave.title} (${roomToSave.id}): createdBy=${roomToSave.createdBy}'); await _roomsBox.put(roomToSave.id, roomToSave); // Traiter les messages récents de la room if (room.recentMessages != null && room.recentMessages!.isNotEmpty) { for (final msgData in room.recentMessages!) { try { final message = Message.fromJson(msgData, _currentUserId, roomId: room.id); // Sauvegarder uniquement si le message n'existe pas déjà if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) { await _messagesBox.put(message.id, message); print('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}'); } else if (message.id.isEmpty) { print('⚠️ Message avec ID vide ignoré'); } } catch (e) { print('⚠️ Erreur lors du traitement d\'un message récent: $e'); } } } } // Supprimer les rooms marquées comme supprimées for (final roomId in deletedRoomIds) { // Supprimer la room await _roomsBox.delete(roomId); // Supprimer tous les messages de cette room final messagesToDelete = _messagesBox.values .where((msg) => msg.roomId == roomId) .map((msg) => msg.id) .toList(); for (final msgId in messagesToDelete) { await _messagesBox.delete(msgId); } print('🗑️ Room $roomId supprimée avec ${messagesToDelete.length} messages'); } print('💾 Sync incrémentale: ${rooms.length} rooms mises à jour, ${deletedRoomIds.length} supprimées'); } // Mettre à jour les stats globales final allRooms = _roomsBox.values.toList(); final totalUnread = allRooms.fold(0, (sum, room) => sum + room.unreadCount); ChatInfoService.instance.updateStats( totalRooms: allRooms.length, unreadMessages: totalUnread, ); return allRooms ..sort((a, b) => (b.lastMessageAt ?? b.createdAt) .compareTo(a.lastMessageAt ?? a.createdAt)); } catch (e) { print('❌ Erreur sync rooms: $e'); // Fallback sur le cache local return _roomsBox.values.toList() ..sort((a, b) => (b.lastMessageAt ?? b.createdAt) .compareTo(a.lastMessageAt ?? a.createdAt)); } } /// Créer une room avec vérification des permissions Future createRoom({ required String title, required List participantIds, String? type, String? initialMessage, }) async { // Générer un ID temporaire pour la room final tempId = 'temp_room_${const Uuid().v4()}'; final now = DateTime.now(); // Créer la room locale immédiatement final tempRoom = Room( id: tempId, title: title, type: type ?? (_currentUserRole == 9 ? 'broadcast' : 'private'), createdAt: now, lastMessage: initialMessage, lastMessageAt: initialMessage != null ? now : null, unreadCount: 0, createdBy: _currentUserId, isSynced: false, // Room non synchronisée ); // Sauvegarder immédiatement dans Hive await _roomsBox.put(tempId, tempRoom); print('💾 Room temporaire sauvée: $tempId'); try { // Vérifier les permissions localement d'abord // L'API fera aussi une vérification final data = { 'title': title, 'type': type ?? (_currentUserRole == 9 ? 'broadcast' : 'private'), 'participants': participantIds, // L'API récupère le rôle et l'entité depuis le token }; // Ajouter le message initial s'il est fourni if (initialMessage != null && initialMessage.isNotEmpty) { data['initial_message'] = initialMessage; } // Utiliser ApiService qui gère automatiquement la queue offline final response = await ApiService.instance.post( '/chat/rooms', data: data, tempId: tempId, // Passer le tempId pour le mapping après sync ); // Vérifier si la room a été mise en queue (offline) if (response.data != null && response.data['queued'] == true) { print('📵 Room mise en file d\'attente pour synchronisation: $tempId'); return tempRoom; // Retourner la room temporaire } // Si online et succès immédiat if (response.statusCode == 200 || response.statusCode == 201) { final realRoom = Room.fromJson(response.data); // Remplacer la room temporaire par la room réelle await _roomsBox.delete(tempId); await _roomsBox.put(realRoom.id, realRoom); print('✅ Room temporaire $tempId remplacée par ${realRoom.id}'); return realRoom; } return tempRoom; } catch (e) { print('⚠️ Erreur création room: $e - La room sera synchronisée plus tard'); // La room reste en local avec isSynced = false return tempRoom; } } /// Créer une conversation one-to-one Future createPrivateRoom({ required int recipientId, required String recipientName, required int recipientRole, int? recipientEntite, String? initialMessage, }) async { // Vérifier les permissions via la configuration if (!canCreateConversationWith(recipientRole, recipientEntite: recipientEntite)) { final messages = ChatConfigLoader.instance.getUIMessages(); throw Exception(messages['no_permission'] ?? 'Permission refusée'); } return createRoom( title: recipientName, participantIds: [recipientId], type: 'private', initialMessage: initialMessage, ); } /// Créer une conversation de groupe (pour superadmin) Future createGroupRoom({ required String title, required List adminIds, String? initialMessage, }) async { if (_currentUserRole != 9) { throw Exception('Seuls les superadmins peuvent créer des groupes'); } return createRoom( title: title, participantIds: adminIds, type: 'broadcast', initialMessage: initialMessage, ); } /// Obtenir les messages d'une room (marque automatiquement comme lu) Future> getMessages(String roomId, {String? beforeMessageId, bool isInitialLoad = false}) async { try { final params = { 'limit': 50, }; if (beforeMessageId != null) { params['before'] = beforeMessageId; } final response = await _dio.get('/chat/rooms/$roomId/messages', queryParameters: params); // Gérer différents formats de réponse List messagesData; bool hasMore = false; int markedAsRead = 0; int unreadRemaining = 0; if (response.data is List) { // Si c'est directement une liste de messages messagesData = response.data as List; } else if (response.data is Map) { // Format avec métadonnées messagesData = response.data['messages'] ?? response.data['data'] ?? []; hasMore = response.data['has_more'] ?? false; markedAsRead = response.data['marked_as_read'] ?? 0; unreadRemaining = response.data['unread_count'] ?? 0; if (markedAsRead > 0) { print('✅ $markedAsRead messages marqués comme lus automatiquement'); } } else { print('⚠️ Format inattendu pour les messages: ${response.data.runtimeType}'); messagesData = []; } final messages = messagesData .map((json) => Message.fromJson(json, _currentUserId, roomId: roomId)) .toList(); print('📨 Messages reçus pour room $roomId: ${messages.length}'); for (final msg in messages) { print(' - ${msg.id}: "${msg.content}" de ${msg.senderName} (${msg.senderId}) isMe: ${msg.isMe}'); } // Sauvegarder dans Hive (en limitant à 100 messages par room) // Si c'est le chargement initial, on remplace tous les messages await _saveMessagesToCache(roomId, messages, replaceAll: isInitialLoad && beforeMessageId == null); // Mettre à jour le unreadCount de la room localement final room = _roomsBox.get(roomId); if (room != null && unreadRemaining == 0) { final updatedRoom = Room( id: room.id, title: room.title, type: room.type, createdAt: room.createdAt, lastMessage: room.lastMessage, lastMessageAt: room.lastMessageAt, unreadCount: 0, // Mis à 0 car tout est lu recentMessages: room.recentMessages, updatedAt: room.updatedAt, ); await _roomsBox.put(roomId, updatedRoom); // Mettre à jour les stats globales ChatInfoService.instance.decrementUnread(markedAsRead); } return { 'messages': messages, 'has_more': hasMore, 'marked_as_read': markedAsRead, }; } catch (e) { print('Erreur getMessages: $e'); // Fallback sur le cache local final cachedMessages = _messagesBox.values .where((m) => m.roomId == roomId) .toList() ..sort((a, b) => a.sentAt.compareTo(b.sentAt)); return { 'messages': cachedMessages, 'has_more': false, 'marked_as_read': 0, }; } } /// Sauvegarder les messages dans le cache en limitant à 100 par room Future _saveMessagesToCache(String roomId, List newMessages, {bool replaceAll = false}) async { // Ajouter les nouveaux messages (en évitant les doublons) int addedCount = 0; for (final message in newMessages) { // Vérifier si le message n'existe pas déjà if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) { await _messagesBox.put(message.id, message); print('💾 Message sauvé: ${message.id} dans room ${message.roomId}'); addedCount++; } else if (_messagesBox.containsKey(message.id)) { print('⚠️ Message ${message.id} existe déjà, ignoré pour éviter duplication'); } } print('📊 Résumé: ${addedCount} nouveaux messages ajoutés sur ${newMessages.length} reçus'); // Après l'ajout, récupérer TOUS les messages de la room pour le nettoyage final allRoomMessages = _messagesBox.values .where((m) => m.roomId == roomId) .toList() ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); // Plus récent en premier // Si on dépasse 100 messages, supprimer les plus anciens if (allRoomMessages.length > 100) { final messagesToDelete = allRoomMessages.skip(100).toList(); print('🗑️ Suppression de ${messagesToDelete.length} anciens messages'); for (final message in messagesToDelete) { await _messagesBox.delete(message.id); } } } /// Supprimer une room (seulement pour le créateur) Future deleteRoom(String roomId) async { try { // Appeler l'API pour supprimer la room await _dio.delete('/chat/rooms/$roomId'); // Supprimer la room localement await _roomsBox.delete(roomId); // Supprimer tous les messages de cette room final messagesToDelete = _messagesBox.values .where((msg) => msg.roomId == roomId) .map((msg) => msg.id) .toList(); for (final msgId in messagesToDelete) { await _messagesBox.delete(msgId); } print('✅ Room $roomId supprimée avec succès'); return true; } catch (e) { print('❌ Erreur suppression room: $e'); throw Exception('Impossible de supprimer la conversation'); } } /// Envoyer un message Future sendMessage(String roomId, String content) async { // Générer un ID temporaire pour le message final tempId = 'temp_msg_${const Uuid().v4()}'; final now = DateTime.now(); // Créer le message local immédiatement (optimistic UI) final tempMessage = Message( id: tempId, roomId: roomId, content: content, senderId: _currentUserId, senderName: _currentUserName, sentAt: now, isMe: true, isRead: false, isSynced: false, // Message non synchronisé ); // Sauvegarder immédiatement dans Hive pour affichage instantané await _messagesBox.put(tempId, tempMessage); print('💾 Message temporaire sauvé: $tempId'); // Mettre à jour la room localement pour affichage immédiat final room = _roomsBox.get(roomId); if (room != null) { final updatedRoom = room.copyWith( lastMessage: content, lastMessageAt: now, unreadCount: 0, updatedAt: now, ); await _roomsBox.put(roomId, updatedRoom); } try { // Utiliser ApiService qui gère automatiquement la queue offline final response = await ApiService.instance.post( '/chat/rooms/$roomId/messages', data: { 'content': content, // L'API récupère le sender depuis le token }, tempId: tempId, // Passer le tempId pour le mapping après sync ); // Vérifier si le message a été mis en queue (offline) if (response.data != null && response.data['queued'] == true) { print('📵 Message mis en file d\'attente pour synchronisation: $tempId'); return tempMessage; // Retourner le message temporaire } // Si online et succès immédiat if (response.statusCode == 200 || response.statusCode == 201) { // Récupérer le message complet depuis la réponse API final realMessage = Message.fromJson( response.data['message'] ?? response.data, _currentUserId, roomId: roomId ); print('📨 Message envoyé avec ID réel: ${realMessage.id}'); // Remplacer le message temporaire par le message réel await _messagesBox.delete(tempId); await _messagesBox.put(realMessage.id, realMessage); print('✅ Message temporaire $tempId remplacé par ${realMessage.id}'); return realMessage; } // Cas par défaut : retourner le message temporaire return tempMessage; } catch (e) { print('⚠️ Erreur envoi message: $e - Le message sera synchronisé plus tard'); // Le message reste en local avec isSynced = false return tempMessage; } } // La méthode markAsRead n'est plus nécessaire car l'API marque automatiquement // les messages comme lus lors de l'appel GET /api/chat/rooms/{roomId}/messages /// Charger le timestamp de dernière sync depuis Hive Future _loadSyncTimestamp() async { try { // Utiliser une box générique pour stocker les métadonnées if (Hive.isBoxOpen('chat_metadata')) { final metaBox = Hive.box('chat_metadata'); final timestamp = metaBox.get('last_sync_timestamp'); if (timestamp != null) { _lastSyncTimestamp = DateTime.parse(timestamp); print('📅 Dernier timestamp de sync restauré: $_lastSyncTimestamp'); } } } catch (e) { print('⚠️ Impossible de charger le timestamp de sync: $e'); } } /// Sauvegarder le timestamp de dernière sync dans Hive Future _saveSyncTimestamp() async { if (_lastSyncTimestamp == null) return; try { // Ouvrir ou créer la box de métadonnées si nécessaire Box metaBox; if (!Hive.isBoxOpen('chat_metadata')) { metaBox = await Hive.openBox('chat_metadata'); } else { metaBox = Hive.box('chat_metadata'); } await metaBox.put('last_sync_timestamp', _lastSyncTimestamp!.toIso8601String()); } catch (e) { print('⚠️ Impossible de sauvegarder le timestamp de sync: $e'); } } /// Synchronisation périodique avec vérification de connectivité void _startSync() { _syncTimer?.cancel(); _syncTimer = Timer.periodic(_syncInterval, (_) async { // Vérifier la connectivité avant de synchroniser if (!connectivityService.isConnected) { print('📵 Pas de connexion - sync ignorée'); return; } // Synchroniser les rooms (incrémentale) await getRooms(); }); // Pas de sync immédiate ici car déjà faite dans init() print('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)'); } /// Mettre en pause les synchronisations (app en arrière-plan) void pauseSyncs() { _syncTimer?.cancel(); print('⏸️ Timer de sync arrêté (app en arrière-plan)'); } /// Reprendre les synchronisations (app au premier plan) void resumeSyncs() { if (_syncTimer == null || !_syncTimer!.isActive) { _startSync(); print('▶️ Timer de sync redémarré (app au premier plan)'); // Faire une sync immédiate au retour au premier plan // pour rattraper les messages manqués if (connectivityService.isConnected) { getRooms().then((_) { print('✅ Sync de rattrapage effectuée'); }).catchError((e) { print('⚠️ Erreur sync de rattrapage: $e'); }); } } } /// Nettoyer les ressources void dispose() { _syncTimer?.cancel(); _dio.close(); } // Getters Box get roomsBox => _roomsBox; Box get messagesBox => _messagesBox; int get currentUserId => _currentUserId; /// Obtenir le rôle de l'utilisateur actuel int getUserRole() => _currentUserRole; int get currentUserRole => _currentUserRole; String get currentUserName => _currentUserName; /// Obtenir le label du rôle String getRoleLabel(int role) { switch (role) { case 1: return 'Membre'; case 2: return 'Admin Amicale'; case 9: return 'Super Admin'; default: return 'Utilisateur'; } } /// Obtenir la description des permissions String getPermissionsDescription() { switch (_currentUserRole) { case 1: return 'Vous pouvez discuter avec les membres de votre amicale'; case 2: return 'Vous pouvez discuter avec les membres et les super admins'; case 9: return 'Vous pouvez envoyer des messages aux administrateurs d\'amicale'; default: return ''; } } }