Files
afterwork/lib/data/services/chat_websocket_service.dart
dahoud 92612abbd7 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
2026-01-10 10:43:17 +00:00

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;
}