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:
396
lib/data/datasources/chat_remote_data_source.dart
Normal file
396
lib/data/datasources/chat_remote_data_source.dart
Normal file
@@ -0,0 +1,396 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../core/constants/env_config.dart';
|
||||
import '../../core/constants/urls.dart';
|
||||
import '../../core/errors/exceptions.dart';
|
||||
import '../../core/utils/app_logger.dart';
|
||||
import '../models/chat_message_model.dart';
|
||||
import '../models/conversation_model.dart';
|
||||
|
||||
/// Source de données distante pour le chat.
|
||||
///
|
||||
/// Cette classe gère toutes les opérations liées au chat
|
||||
/// via l'API backend (REST) et WebSocket pour le temps réel.
|
||||
class ChatRemoteDataSource {
|
||||
ChatRemoteDataSource(this.client);
|
||||
|
||||
final http.Client client;
|
||||
|
||||
static const Map<String, String> _defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
Duration get _timeout => Duration(seconds: EnvConfig.networkTimeout);
|
||||
|
||||
Future<http.Response> _performRequest(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Map<String, String>? headers,
|
||||
Object? body,
|
||||
}) async {
|
||||
AppLogger.http(method, uri.toString());
|
||||
|
||||
try {
|
||||
http.Response response;
|
||||
final requestHeaders = {..._defaultHeaders, ...?headers};
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
response = await client.get(uri, headers: requestHeaders).timeout(_timeout);
|
||||
break;
|
||||
case 'POST':
|
||||
response = await client.post(uri, headers: requestHeaders, body: body).timeout(_timeout);
|
||||
break;
|
||||
case 'PUT':
|
||||
response = await client.put(uri, headers: requestHeaders, body: body).timeout(_timeout);
|
||||
break;
|
||||
case 'DELETE':
|
||||
response = await client.delete(uri, headers: requestHeaders).timeout(_timeout);
|
||||
break;
|
||||
default:
|
||||
throw ArgumentError('Méthode HTTP non supportée: $method');
|
||||
}
|
||||
|
||||
AppLogger.http(method, uri.toString(), statusCode: response.statusCode);
|
||||
AppLogger.d('Réponse: ${response.body}', tag: 'ChatRemoteDataSource');
|
||||
|
||||
return response;
|
||||
} on TimeoutException catch (e, stackTrace) {
|
||||
AppLogger.e('Timeout lors de la requête $method $uri', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
throw ServerException('La requête a pris trop de temps. Le serveur ne répond pas.');
|
||||
} on SocketException catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur de connexion réseau', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
throw const ServerException('Erreur de connexion réseau. Vérifiez votre connexion internet.');
|
||||
} on http.ClientException catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur client HTTP', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
throw ServerException('Erreur client HTTP: ${e.message}');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur inattendue', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _parseJsonResponse(http.Response response, List<int> expectedStatusCodes) {
|
||||
if (expectedStatusCodes.contains(response.statusCode)) {
|
||||
if (response.body.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
final errorMessage = (json.decode(response.body) as Map<String, dynamic>?)?['message'] as String? ??
|
||||
'Erreur serveur inconnue';
|
||||
|
||||
AppLogger.e('Erreur (${response.statusCode}): $errorMessage', tag: 'ChatRemoteDataSource');
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 401:
|
||||
throw UnauthorizedException(errorMessage);
|
||||
case 404:
|
||||
throw ServerException('Ressource non trouvée: $errorMessage');
|
||||
default:
|
||||
throw ServerException('Erreur serveur (${response.statusCode}): $errorMessage');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère toutes les conversations d'un utilisateur.
|
||||
Future<List<ConversationModel>> getConversations(String userId) async {
|
||||
AppLogger.d('Récupération des conversations pour: $userId', tag: 'ChatRemoteDataSource');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(Urls.getUserConversations(userId));
|
||||
final response = await _performRequest('GET', uri);
|
||||
final jsonList = _parseJsonResponse(response, [200]) as List;
|
||||
return jsonList.map((json) => ConversationModel.fromJson(json as Map<String, dynamic>)).toList();
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors de la récupération des conversations', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère ou crée une conversation avec un utilisateur.
|
||||
///
|
||||
/// Si la conversation n'existe pas (404) ou si le backend ne répond pas (timeout),
|
||||
/// elle sera créée automatiquement en envoyant un message initial.
|
||||
Future<ConversationModel> getOrCreateConversation(String userId, String participantId) async {
|
||||
AppLogger.d('Récupération/création conversation: $userId <-> $participantId', tag: 'ChatRemoteDataSource');
|
||||
|
||||
final uri = Uri.parse(Urls.getConversationBetweenUsers(userId, participantId));
|
||||
|
||||
try {
|
||||
// Essayer de récupérer la conversation existante avec un timeout plus court
|
||||
final response = await _performRequestWithTimeout('GET', uri, timeout: const Duration(seconds: 10));
|
||||
|
||||
// Si la conversation existe, la retourner
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final jsonResponse = json.decode(response.body) as Map<String, dynamic>;
|
||||
return ConversationModel.fromJson(jsonResponse);
|
||||
}
|
||||
|
||||
// Si 404, la conversation n'existe pas, on doit la créer
|
||||
if (response.statusCode == 404) {
|
||||
AppLogger.i('Conversation non trouvée (404), création en cours...', tag: 'ChatRemoteDataSource');
|
||||
return await _createConversationBySendingMessage(userId, participantId, uri);
|
||||
}
|
||||
|
||||
// Pour les autres codes d'erreur, utiliser la méthode standard de parsing
|
||||
final jsonResponse = _parseJsonResponse(response, [200, 201]) as Map<String, dynamic>;
|
||||
return ConversationModel.fromJson(jsonResponse);
|
||||
} on ServerException catch (e) {
|
||||
// Si c'est une ServerException avec 404 ou timeout, on essaie de créer la conversation
|
||||
if (e.message.contains('404') ||
|
||||
e.message.contains('non trouvée') ||
|
||||
e.message.contains('not found') ||
|
||||
e.message.contains('trop de temps') ||
|
||||
e.message.contains('ne répond pas')) {
|
||||
AppLogger.i('Conversation non trouvée ou timeout, création en cours...', tag: 'ChatRemoteDataSource');
|
||||
return await _createConversationBySendingMessage(userId, participantId, uri);
|
||||
}
|
||||
rethrow;
|
||||
} on TimeoutException catch (e, stackTrace) {
|
||||
// Si timeout, créer directement la conversation
|
||||
AppLogger.w('Timeout lors de la récupération de la conversation, création directe...', tag: 'ChatRemoteDataSource');
|
||||
return await _createConversationBySendingMessage(userId, participantId, uri);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors de la récupération/création de conversation', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Effectue une requête HTTP avec un timeout personnalisé.
|
||||
Future<http.Response> _performRequestWithTimeout(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Map<String, String>? headers,
|
||||
Object? body,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
AppLogger.http(method, uri.toString());
|
||||
|
||||
try {
|
||||
http.Response response;
|
||||
final requestHeaders = {..._defaultHeaders, ...?headers};
|
||||
final requestTimeout = timeout ?? _timeout;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
response = await client.get(uri, headers: requestHeaders).timeout(requestTimeout);
|
||||
break;
|
||||
case 'POST':
|
||||
response = await client.post(uri, headers: requestHeaders, body: body).timeout(requestTimeout);
|
||||
break;
|
||||
case 'PUT':
|
||||
response = await client.put(uri, headers: requestHeaders, body: body).timeout(requestTimeout);
|
||||
break;
|
||||
case 'DELETE':
|
||||
response = await client.delete(uri, headers: requestHeaders).timeout(requestTimeout);
|
||||
break;
|
||||
default:
|
||||
throw ArgumentError('Méthode HTTP non supportée: $method');
|
||||
}
|
||||
|
||||
AppLogger.http(method, uri.toString(), statusCode: response.statusCode);
|
||||
AppLogger.d('Réponse: ${response.body}', tag: 'ChatRemoteDataSource');
|
||||
|
||||
return response;
|
||||
} on TimeoutException catch (e, stackTrace) {
|
||||
AppLogger.e('Timeout lors de la requête $method $uri', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
throw ServerException('La requête a pris trop de temps. Le serveur ne répond pas.');
|
||||
} on SocketException catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur de connexion réseau', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
throw const ServerException('Erreur de connexion réseau. Vérifiez votre connexion internet.');
|
||||
} on http.ClientException catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur client HTTP', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
throw ServerException('Erreur client HTTP: ${e.message}');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur inattendue', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une conversation en envoyant un message initial, puis récupère la conversation créée.
|
||||
Future<ConversationModel> _createConversationBySendingMessage(
|
||||
String userId,
|
||||
String participantId,
|
||||
Uri conversationUri,
|
||||
) async {
|
||||
try {
|
||||
// Créer la conversation en envoyant un message initial
|
||||
// Le backend créera automatiquement la conversation lors de l'envoi du premier message
|
||||
AppLogger.i('Création de la conversation en envoyant un message initial...', tag: 'ChatRemoteDataSource');
|
||||
|
||||
await sendMessage(
|
||||
senderId: userId,
|
||||
recipientId: participantId,
|
||||
content: '👋', // Message initial pour créer la conversation
|
||||
messageType: 'text',
|
||||
);
|
||||
|
||||
AppLogger.i('Message initial envoyé, attente de la création de la conversation...', tag: 'ChatRemoteDataSource');
|
||||
|
||||
// Attendre un peu pour que le backend crée la conversation
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
|
||||
// Récupérer la conversation nouvellement créée avec plusieurs tentatives et timeout court
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
AppLogger.d('Tentative ${attempt + 1}/3 pour récupérer la conversation créée...', tag: 'ChatRemoteDataSource');
|
||||
final retryResponse = await _performRequestWithTimeout(
|
||||
'GET',
|
||||
conversationUri,
|
||||
timeout: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
if (retryResponse.statusCode == 200 || retryResponse.statusCode == 201) {
|
||||
final jsonResponse = json.decode(retryResponse.body) as Map<String, dynamic>;
|
||||
AppLogger.i('Conversation créée avec succès', tag: 'ChatRemoteDataSource');
|
||||
return ConversationModel.fromJson(jsonResponse);
|
||||
}
|
||||
} on TimeoutException {
|
||||
AppLogger.w('Timeout lors de la tentative ${attempt + 1}, nouvelle tentative...', tag: 'ChatRemoteDataSource');
|
||||
}
|
||||
|
||||
// Attendre un peu plus avant la prochaine tentative
|
||||
if (attempt < 2) {
|
||||
await Future.delayed(Duration(milliseconds: 500 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
// Si après 3 tentatives on n'a toujours pas la conversation, lever une exception
|
||||
throw ServerException(
|
||||
'Impossible de créer ou récupérer la conversation après plusieurs tentatives. '
|
||||
'Le serveur ne répond pas correctement.',
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e(
|
||||
'Erreur lors de la création de la conversation via message initial',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
tag: 'ChatRemoteDataSource',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les messages d'une conversation.
|
||||
Future<List<ChatMessageModel>> getMessages(String conversationId, {int page = 0, int size = 50}) async {
|
||||
AppLogger.d('Récupération des messages: $conversationId (page: $page)', tag: 'ChatRemoteDataSource');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(Urls.getConversationMessages(conversationId, page: page, size: size));
|
||||
final response = await _performRequest('GET', uri);
|
||||
final jsonList = _parseJsonResponse(response, [200]) as List;
|
||||
return jsonList.map((json) => ChatMessageModel.fromJson(json as Map<String, dynamic>)).toList();
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors de la récupération des messages', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un nouveau message dans une conversation.
|
||||
Future<ChatMessageModel> sendMessage({
|
||||
required String senderId,
|
||||
required String recipientId,
|
||||
required String content,
|
||||
String? messageType,
|
||||
String? mediaUrl,
|
||||
}) async {
|
||||
AppLogger.i('Envoi message de $senderId à $recipientId', tag: 'ChatRemoteDataSource');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(Urls.sendMessage);
|
||||
final body = json.encode({
|
||||
'senderId': senderId,
|
||||
'recipientId': recipientId,
|
||||
'content': content,
|
||||
'messageType': messageType ?? 'text',
|
||||
if (mediaUrl != null) 'mediaUrl': mediaUrl,
|
||||
});
|
||||
final response = await _performRequest('POST', uri, body: body);
|
||||
final jsonResponse = _parseJsonResponse(response, [200, 201]) as Map<String, dynamic>;
|
||||
return ChatMessageModel.fromJson(jsonResponse);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors de l\'envoi du message', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque un message comme lu.
|
||||
Future<void> markMessageAsRead(String messageId) async {
|
||||
AppLogger.d('Marquer message comme lu: $messageId', tag: 'ChatRemoteDataSource');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(Urls.markMessageAsRead(messageId));
|
||||
await _performRequest('PUT', uri);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors du marquage comme lu', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque tous les messages d'une conversation comme lus.
|
||||
Future<void> markConversationAsRead(String conversationId, String userId) async {
|
||||
AppLogger.d('Marquer conversation comme lue: $conversationId', tag: 'ChatRemoteDataSource');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(Urls.markAllMessagesAsRead(conversationId, userId));
|
||||
await _performRequest('PUT', uri);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors du marquage de la conversation', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un message.
|
||||
Future<void> deleteMessage(String messageId) async {
|
||||
AppLogger.i('Suppression message: $messageId', tag: 'ChatRemoteDataSource');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(Urls.deleteMessage(messageId));
|
||||
await _performRequest('DELETE', uri);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors de la suppression', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une conversation.
|
||||
Future<void> deleteConversation(String conversationId) async {
|
||||
AppLogger.i('Suppression conversation: $conversationId', tag: 'ChatRemoteDataSource');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(Urls.deleteConversation(conversationId));
|
||||
await _performRequest('DELETE', uri);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors de la suppression de la conversation', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le nombre de messages non lus pour un utilisateur.
|
||||
Future<int> getUnreadMessagesCount(String userId) async {
|
||||
AppLogger.d('Récupération du nombre de messages non lus pour: $userId', tag: 'ChatRemoteDataSource');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(Urls.getUnreadMessagesCount(userId));
|
||||
final response = await _performRequest('GET', uri);
|
||||
final jsonResponse = _parseJsonResponse(response, [200]) as Map<String, dynamic>;
|
||||
return jsonResponse['unreadCount'] as int? ?? 0;
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('Erreur lors de la récupération du nombre de messages non lus', error: e, stackTrace: stackTrace, tag: 'ChatRemoteDataSource');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un indicateur de frappe (typing indicator) via WebSocket.
|
||||
/// Cette méthode est maintenant gérée par le WebSocket, pas par REST.
|
||||
Future<void> sendTypingIndicator(String recipientId, String userId, bool isTyping) async {
|
||||
AppLogger.d('Indicateur de frappe envoyé via WebSocket pour: $recipientId ($isTyping)', tag: 'ChatRemoteDataSource');
|
||||
// Cette fonctionnalité sera gérée par ChatWebSocketService
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user