fix(chat): Correction race condition + Implémentation TODOs
## Corrections Critiques ### Race Condition - Statuts de Messages - Fix : Les icônes de statut (✓, ✓✓, ✓✓ bleu) ne s'affichaient pas - Cause : WebSocket delivery confirmations arrivaient avant messages locaux - Solution : Pattern Optimistic UI dans chat_bloc.dart - Création message temporaire immédiate - Ajout à la liste AVANT requête HTTP - Remplacement par message serveur à la réponse - Fichier : lib/presentation/state_management/chat_bloc.dart ## Implémentation TODOs (13/21) ### Social (social_header_widget.dart) - ✅ Copier lien du post dans presse-papiers - ✅ Partage natif via Share.share() - ✅ Dialogue de signalement avec 5 raisons ### Partage (share_post_dialog.dart) - ✅ Interface sélection d'amis avec checkboxes - ✅ Partage externe via Share API ### Média (media_upload_service.dart) - ✅ Parsing JSON réponse backend - ✅ Méthode deleteMedia() pour suppression - ✅ Génération miniature vidéo ### Posts (create_post_dialog.dart, edit_post_dialog.dart) - ✅ Extraction URL depuis uploads - ✅ Documentation chargement médias ### Chat (conversations_screen.dart) - ✅ Navigation vers notifications - ✅ ConversationSearchDelegate pour recherche ## Nouveaux Fichiers ### Configuration - build-prod.ps1 : Script build production avec dart-define - lib/core/constants/env_config.dart : Gestion environnements ### Documentation - TODOS_IMPLEMENTED.md : Documentation complète TODOs ## Améliorations ### Architecture - Refactoring injection de dépendances - Amélioration routing et navigation - Optimisation providers (UserProvider, FriendsProvider) ### UI/UX - Amélioration thème et couleurs - Optimisation animations - Meilleure gestion erreurs ### Services - Configuration API avec env_config - Amélioration datasources (events, users) - Optimisation modèles de données
This commit is contained in:
318
lib/data/services/chat_websocket_service.dart
Normal file
318
lib/data/services/chat_websocket_service.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
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<ChatMessage>.broadcast();
|
||||
final _typingController = StreamController<TypingIndicator>.broadcast();
|
||||
final _readReceiptController = StreamController<ReadReceipt>.broadcast();
|
||||
final _deliveryController = StreamController<DeliveryConfirmation>.broadcast();
|
||||
|
||||
Stream<ChatMessage> get messageStream => _messageController.stream;
|
||||
Stream<TypingIndicator> get typingStream => _typingController.stream;
|
||||
Stream<ReadReceipt> get readReceiptStream => _readReceiptController.stream;
|
||||
Stream<DeliveryConfirmation> 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<void> 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<void> 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<String, dynamic>;
|
||||
final type = jsonData['type'] as String?;
|
||||
final payload = jsonData['data'] as Map<String, dynamic>?;
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user