## 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
319 lines
9.4 KiB
Dart
319 lines
9.4 KiB
Dart
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;
|
|
}
|