import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../../core/constants/env_config.dart'; import '../../core/utils/app_logger.dart'; /// Service WebSocket pour les notifications en temps réel. /// /// Ce service gère les connexions WebSocket pour recevoir : /// - Demandes d'amitié (envoi, réception, acceptation, rejet) /// - Notifications système (événements, rappels) /// - Alertes de messages /// /// **Architecture :** /// - Connexion WebSocket persistante à `/notifications/ws/{userId}` /// - Streams séparés par type de notification /// - Reconnexion automatique en cas de déconnexion /// - Support multi-sessions (l'utilisateur peut être connecté sur plusieurs appareils) class RealtimeNotificationService extends ChangeNotifier { RealtimeNotificationService(this.userId); final String userId; WebSocketChannel? _channel; StreamSubscription? _subscription; bool _isConnected = false; bool get isConnected => _isConnected; bool _isDisposed = false; Timer? _reconnectTimer; // Streams pour différents types d'événements final _friendRequestController = StreamController.broadcast(); final _systemNotificationController = StreamController.broadcast(); final _messageAlertController = StreamController.broadcast(); final _presenceController = StreamController.broadcast(); Stream get friendRequestStream => _friendRequestController.stream; Stream get systemNotificationStream => _systemNotificationController.stream; Stream get messageAlertStream => _messageAlertController.stream; Stream get presenceStream => _presenceController.stream; /// Récupère l'URL WebSocket à partir de l'URL HTTP de base. String get _wsUrl { final baseUrl = EnvConfig.apiBaseUrl; // Remplacer http:// par ws:// ou https:// par wss:// final wsUrl = baseUrl.replaceFirst('http://', 'ws://').replaceFirst('https://', 'wss://'); return '$wsUrl/notifications/ws/$userId'; } /// Se connecte au serveur WebSocket. Future connect() async { if (_isDisposed) { AppLogger.w('Tentative de connexion après dispose, ignorée', tag: 'RealtimeNotificationService'); return; } if (_isConnected) { AppLogger.w('Déjà connecté', tag: 'RealtimeNotificationService'); return; } try { AppLogger.i('Connexion à: $_wsUrl', tag: 'RealtimeNotificationService'); _channel = WebSocketChannel.connect(Uri.parse(_wsUrl)); // Écouter les messages entrants _subscription = _channel!.stream.listen( _handleMessage, onError: _handleError, onDone: _handleDisconnection, cancelOnError: false, ); _isConnected = true; if (!_isDisposed) { notifyListeners(); } AppLogger.i('Connecté avec succès au service de notifications', tag: 'RealtimeNotificationService'); } catch (e, stackTrace) { AppLogger.e('Erreur de connexion', error: e, stackTrace: stackTrace, tag: 'RealtimeNotificationService'); _isConnected = false; if (!_isDisposed) { notifyListeners(); } } } /// Déconnecte du serveur WebSocket. Future disconnect() async { _reconnectTimer?.cancel(); _reconnectTimer = null; if (!_isConnected) return; AppLogger.i('Déconnexion...', tag: 'RealtimeNotificationService'); await _subscription?.cancel(); _subscription = null; try { await _channel?.sink.close(); } catch (e) { AppLogger.w('Erreur lors de la fermeture du canal: $e', tag: 'RealtimeNotificationService'); } _channel = null; _isConnected = false; if (!_isDisposed) { notifyListeners(); } AppLogger.i('Déconnecté du service de notifications', tag: 'RealtimeNotificationService'); } /// Envoie un ping pour maintenir la connexion active (keep-alive). void sendPing() { if (!_isConnected) { AppLogger.w('Impossible d\'envoyer un ping: non connecté', tag: 'RealtimeNotificationService'); return; } final payload = { 'type': 'ping', }; _channel?.sink.add(json.encode(payload)); AppLogger.d('Ping envoyé', tag: 'RealtimeNotificationService'); } /// Envoie un heartbeat pour maintenir le statut online. void sendHeartbeat() { sendPing(); // Le ping est géré côté serveur pour mettre à jour la présence } /// Envoie un accusé de réception pour une notification. void sendAcknowledgement(String notificationId) { if (!_isConnected) return; final payload = { 'type': 'ack', 'data': { 'notificationId': notificationId, }, }; _channel?.sink.add(json.encode(payload)); AppLogger.d('ACK envoyé pour notification: $notificationId', tag: 'RealtimeNotificationService'); } /// Gère les messages entrants du WebSocket. void _handleMessage(dynamic data) { try { final jsonData = json.decode(data as String) as Map; final type = jsonData['type'] as String?; final payload = jsonData['data'] as Map?; if (type == 'connected') { AppLogger.i('Confirmation de connexion reçue', tag: 'RealtimeNotificationService'); return; } if (type == 'pong') { AppLogger.d('Pong reçu', tag: 'RealtimeNotificationService'); return; } if (payload == null) { AppLogger.w('Payload null pour le type: $type', tag: 'RealtimeNotificationService'); return; } switch (type) { case 'friend_request_received': final notification = FriendRequestNotification.fromJson(payload, type: 'received'); _friendRequestController.add(notification); AppLogger.i('Demande d\'amitié reçue de: ${notification.senderName}', tag: 'RealtimeNotificationService'); break; case 'friend_request_accepted': final notification = FriendRequestNotification.fromJson(payload, type: 'accepted'); _friendRequestController.add(notification); AppLogger.i('Demande d\'amitié acceptée par: ${notification.senderName}', tag: 'RealtimeNotificationService'); break; case 'friend_request_rejected': final notification = FriendRequestNotification.fromJson(payload, type: 'rejected'); _friendRequestController.add(notification); AppLogger.i('Demande d\'amitié rejetée: ${notification.requestId}', tag: 'RealtimeNotificationService'); break; case 'message_received': final alert = MessageAlert.fromJson(payload); _messageAlertController.add(alert); AppLogger.i('Nouveau message de: ${alert.senderName}', tag: 'RealtimeNotificationService'); break; case 'system_notification': final notification = SystemNotification.fromJson(payload); _systemNotificationController.add(notification); AppLogger.i('Notification système: ${notification.title}', tag: 'RealtimeNotificationService'); break; case 'presence': final update = PresenceUpdate.fromJson(payload); _presenceController.add(update); AppLogger.i('Mise à jour présence: ${update.userId} -> ${update.isOnline}', tag: 'RealtimeNotificationService'); break; default: AppLogger.w('Type de notification inconnu: $type', tag: 'RealtimeNotificationService'); } } catch (e, stackTrace) { AppLogger.e('Erreur parsing notification', error: e, stackTrace: stackTrace, tag: 'RealtimeNotificationService'); } } /// Gère les erreurs WebSocket. void _handleError(Object error) { AppLogger.w('Erreur WebSocket: $error', tag: 'RealtimeNotificationService'); _isConnected = false; if (!_isDisposed) { notifyListeners(); } // Ne pas tenter de reconnexion si le backend ne supporte pas WebSocket if (error.toString().contains('not upgraded to websocket')) { AppLogger.w('WebSocket non supporté par le backend, reconnexion désactivée', tag: 'RealtimeNotificationService'); return; } // Tentative de reconnexion après 5 secondes seulement si pas disposé if (!_isDisposed && _reconnectTimer == null) { _reconnectTimer = Timer(const Duration(seconds: 5), () { if (!_isDisposed && !_isConnected) { AppLogger.i('Tentative de reconnexion...', tag: 'RealtimeNotificationService'); _reconnectTimer = null; connect(); } }); } } /// Gère la déconnexion WebSocket. void _handleDisconnection() { AppLogger.i('Déconnexion détectée', tag: 'RealtimeNotificationService'); _isConnected = false; if (!_isDisposed) { notifyListeners(); } // Tenter une reconnexion if (!_isDisposed && _reconnectTimer == null) { _reconnectTimer = Timer(const Duration(seconds: 5), () { if (!_isDisposed && !_isConnected) { AppLogger.i('Reconnexion après déconnexion...', tag: 'RealtimeNotificationService'); _reconnectTimer = null; connect(); } }); } } @override void dispose() { _isDisposed = true; _reconnectTimer?.cancel(); _reconnectTimer = null; disconnect(); _friendRequestController.close(); _systemNotificationController.close(); _messageAlertController.close(); _presenceController.close(); super.dispose(); } } /// Notification de demande d'amitié. class FriendRequestNotification { const FriendRequestNotification({ required this.type, required this.requestId, required this.senderId, required this.senderName, required this.senderProfileImage, }); /// Type: 'received', 'accepted', 'rejected' final String type; /// ID de la demande d'amitié (friendshipId) final String requestId; /// ID de l'utilisateur qui a envoyé/accepté/rejeté final String senderId; /// Nom complet de l'utilisateur final String senderName; /// URL de l'image de profil final String senderProfileImage; factory FriendRequestNotification.fromJson(Map json, {required String type}) { return FriendRequestNotification( type: type, requestId: json['requestId']?.toString() ?? json['friendshipId']?.toString() ?? '', senderId: json['senderId']?.toString() ?? json['accepterId']?.toString() ?? '', senderName: json['senderName']?.toString() ?? json['acceptedBy']?.toString() ?? 'Utilisateur', senderProfileImage: json['senderProfileImage']?.toString() ?? json['accepterProfileImage']?.toString() ?? '', ); } String toDisplayMessage() { switch (type) { case 'received': return 'Nouvelle demande d\'amitié de $senderName'; case 'accepted': return '$senderName a accepté votre demande'; case 'rejected': return 'Demande d\'amitié refusée'; default: return 'Notification de demande d\'amitié'; } } } /// Notification système. class SystemNotification { const SystemNotification({ required this.notificationId, required this.title, required this.message, required this.type, required this.timestamp, }); final String notificationId; final String title; final String message; final String type; // 'event', 'friend', 'reminder', 'other' final DateTime timestamp; factory SystemNotification.fromJson(Map json) { return SystemNotification( notificationId: json['notificationId']?.toString() ?? '', title: json['title']?.toString() ?? 'Notification', message: json['message']?.toString() ?? '', type: json['type']?.toString() ?? 'other', timestamp: json['timestamp'] != null ? DateTime.parse(json['timestamp'].toString()) : DateTime.now(), ); } } /// Alerte de message. class MessageAlert { const MessageAlert({ required this.messageId, required this.conversationId, required this.senderId, required this.senderName, required this.content, required this.timestamp, }); final String messageId; final String conversationId; final String senderId; final String senderName; final String content; final DateTime timestamp; factory MessageAlert.fromJson(Map json) { return MessageAlert( messageId: json['messageId']?.toString() ?? '', conversationId: json['conversationId']?.toString() ?? '', senderId: json['senderId']?.toString() ?? '', senderName: json['senderName']?.toString() ?? 'Utilisateur', content: json['content']?.toString() ?? '', timestamp: json['timestamp'] != null ? DateTime.parse(json['timestamp'].toString()) : DateTime.now(), ); } } /// Mise à jour de présence utilisateur. class PresenceUpdate { const PresenceUpdate({ required this.userId, required this.isOnline, this.lastSeen, required this.timestamp, }); final String userId; final bool isOnline; final DateTime? lastSeen; final DateTime timestamp; factory PresenceUpdate.fromJson(Map json) { return PresenceUpdate( userId: json['userId']?.toString() ?? '', isOnline: json['isOnline'] as bool? ?? false, lastSeen: json['lastSeen'] != null && json['lastSeen'].toString().isNotEmpty ? DateTime.tryParse(json['lastSeen'].toString()) : null, timestamp: json['timestamp'] != null ? DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int) : DateTime.now(), ); } }