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'; import '../../domain/entities/chat_message.dart'; import '../models/chat_message_model.dart'; /// Service WebSocket pour la communication temps réel du chat. /// /// Ce service gère : /// - La connexion WebSocket au serveur /// - La réception de nouveaux messages en temps réel /// - Les indicateurs de frappe (typing indicators) /// - Les confirmations de lecture (read receipts) class ChatWebSocketService extends ChangeNotifier { ChatWebSocketService(this.userId); final String userId; WebSocketChannel? _channel; StreamSubscription? _subscription; bool _isConnected = false; bool get isConnected => _isConnected; bool _isDisposed = false; Timer? _reconnectTimer; // Streams pour les événements temps réel final _messageController = StreamController.broadcast(); final _typingController = StreamController.broadcast(); final _readReceiptController = StreamController.broadcast(); final _deliveryController = StreamController.broadcast(); Stream get messageStream => _messageController.stream; Stream get typingStream => _typingController.stream; Stream get readReceiptStream => _readReceiptController.stream; Stream get deliveryStream => _deliveryController.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/chat/ws/$userId'; } /// Se connecte au serveur WebSocket. Future connect() async { if (_isDisposed) { AppLogger.w('Tentative de connexion après dispose, ignorée', tag: 'ChatWebSocketService'); return; } if (_isConnected) { AppLogger.w('Déjà connecté', tag: 'ChatWebSocketService'); return; } try { AppLogger.i('Connexion à: $_wsUrl', tag: 'ChatWebSocketService'); _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', tag: 'ChatWebSocketService'); } catch (e, stackTrace) { AppLogger.e('Erreur de connexion', error: e, stackTrace: stackTrace, tag: 'ChatWebSocketService'); _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: 'ChatWebSocketService'); await _subscription?.cancel(); _subscription = null; try { await _channel?.sink.close(); } catch (e) { AppLogger.w('Erreur lors de la fermeture du canal: $e', tag: 'ChatWebSocketService'); } _channel = null; _isConnected = false; if (!_isDisposed) { notifyListeners(); } AppLogger.i('Déconnecté', tag: 'ChatWebSocketService'); } /// Envoie un message via WebSocket. void sendMessage(ChatMessage message) { if (!_isConnected) { AppLogger.w('Erreur: Non connecté', tag: 'ChatWebSocketService'); return; } final model = ChatMessageModel.fromEntity(message); final payload = { 'type': 'message', 'data': model.toJson(), }; _channel?.sink.add(json.encode(payload)); AppLogger.d('Message envoyé: ${message.id}', tag: 'ChatWebSocketService'); } /// Envoie un indicateur de frappe. void sendTypingIndicator(String conversationId, bool isTyping) { if (!_isConnected) return; final payload = { 'type': 'typing', 'data': { 'conversationId': conversationId, 'userId': userId, 'isTyping': isTyping, }, }; _channel?.sink.add(json.encode(payload)); AppLogger.d('Indicateur de frappe envoyé: $isTyping', tag: 'ChatWebSocketService'); } /// Envoie une confirmation de lecture. void sendReadReceipt(String messageId) { if (!_isConnected) return; final payload = { 'type': 'read', 'data': { 'messageId': messageId, 'userId': userId, }, }; _channel?.sink.add(json.encode(payload)); AppLogger.d('Confirmation de lecture envoyée: $messageId', tag: 'ChatWebSocketService'); } /// 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 (payload == null) return; switch (type) { case 'message': final message = ChatMessageModel.fromJson(payload).toEntity(); _messageController.add(message); AppLogger.d('Nouveau message reçu: ${message.id}', tag: 'ChatWebSocketService'); break; case 'typing': final indicator = TypingIndicator( conversationId: payload['conversationId'] as String, userId: payload['userId'] as String, isTyping: payload['isTyping'] as bool, ); _typingController.add(indicator); AppLogger.d('Indicateur de frappe: ${indicator.isTyping}', tag: 'ChatWebSocketService'); break; case 'read': AppLogger.d('RECEIVED READ: $payload', tag: 'ChatWebSocketService'); final receipt = ReadReceipt( messageId: payload['messageId'] as String, userId: payload['userId'] as String, timestamp: DateTime.parse(payload['timestamp'] as String), ); _readReceiptController.add(receipt); AppLogger.d('Confirmation lecture ajoutée au stream: ${receipt.messageId}', tag: 'ChatWebSocketService'); break; case 'delivered': AppLogger.d('RECEIVED DELIVERED: $payload', tag: 'ChatWebSocketService'); final confirmation = DeliveryConfirmation( messageId: payload['messageId'] as String, isDelivered: payload['isDelivered'] as bool, timestamp: DateTime.fromMillisecondsSinceEpoch( payload['timestamp'] as int, ), ); _deliveryController.add(confirmation); AppLogger.d('Confirmation délivrance ajoutée au stream: ${confirmation.messageId}', tag: 'ChatWebSocketService'); break; default: AppLogger.w('Type de message inconnu: $type', tag: 'ChatWebSocketService'); } } catch (e, stackTrace) { AppLogger.e('Erreur parsing message', error: e, stackTrace: stackTrace, tag: 'ChatWebSocketService'); } } /// Gère les erreurs WebSocket. void _handleError(Object error) { AppLogger.w('Erreur WebSocket: $error', tag: 'ChatWebSocketService'); _isConnected = false; if (!_isDisposed) { notifyListeners(); } // Ne pas tenter de reconnexion si le backend ne supporte pas WebSocket // Le backend retourne "Connection was not upgraded to websocket" if (error.toString().contains('not upgraded to websocket')) { AppLogger.w('WebSocket non supporté par le backend, reconnexion désactivée', tag: 'ChatWebSocketService'); 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: 'ChatWebSocketService'); _reconnectTimer = null; connect(); } }); } } /// Gère la déconnexion WebSocket. void _handleDisconnection() { AppLogger.i('Déconnexion détectée', tag: 'ChatWebSocketService'); _isConnected = false; if (!_isDisposed) { notifyListeners(); } } @override void dispose() { _isDisposed = true; _reconnectTimer?.cancel(); _reconnectTimer = null; disconnect(); _messageController.close(); _typingController.close(); _readReceiptController.close(); _deliveryController.close(); super.dispose(); } } /// Indicateur de frappe. class TypingIndicator { const TypingIndicator({ required this.conversationId, required this.userId, required this.isTyping, }); final String conversationId; final String userId; final bool isTyping; } /// Confirmation de lecture. class ReadReceipt { const ReadReceipt({ required this.messageId, required this.userId, required this.timestamp, }); final String messageId; final String userId; final DateTime timestamp; } /// Confirmation de délivrance. class DeliveryConfirmation { const DeliveryConfirmation({ required this.messageId, required this.isDelivered, required this.timestamp, }); final String messageId; final bool isDelivered; final DateTime timestamp; }