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:
dahoud
2026-01-10 10:43:17 +00:00
parent 06031b01f2
commit 92612abbd7
321 changed files with 43137 additions and 4285 deletions

View File

@@ -0,0 +1,580 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/utils/app_logger.dart';
import '../../domain/entities/chat_message.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/repositories/chat_repository.dart';
/// Bloc pour la gestion des événements et états liés au chat.
///
/// Ce bloc gère:
/// - Le chargement des conversations
/// - Le chargement des messages d'une conversation
/// - L'envoi de messages
/// - Le marquage des messages comme lus
/// - La suppression de messages et conversations
/// - Le compteur de messages non lus
class ChatBloc extends Bloc<ChatEvent, ChatState> {
/// Constructeur avec injection du repository de chat.
ChatBloc({required this.chatRepository}) : super(const ChatState()) {
on<LoadConversations>(_onLoadConversations);
on<LoadMessages>(_onLoadMessages);
on<SendMessage>(_onSendMessage);
on<MarkMessageAsRead>(_onMarkMessageAsRead);
on<MarkMessageAsDelivered>(_onMarkMessageAsDelivered);
on<MarkConversationAsRead>(_onMarkConversationAsRead);
on<DeleteMessage>(_onDeleteMessage);
on<DeleteConversation>(_onDeleteConversation);
on<LoadUnreadCount>(_onLoadUnreadCount);
on<AddMessageToConversation>(_onAddMessageToConversation);
on<UpdateTypingStatus>(_onUpdateTypingStatus);
}
final ChatRepository chatRepository;
void _log(String message) {
AppLogger.d(message, tag: 'ChatBloc');
}
/// Charge toutes les conversations d'un utilisateur.
Future<void> _onLoadConversations(
LoadConversations event,
Emitter<ChatState> emit,
) async {
_log('Chargement des conversations pour ${event.userId}');
emit(state.copyWith(conversationsStatus: ConversationsStatus.loading));
final result = await chatRepository.getConversations(event.userId);
result.fold(
(failure) {
_log('Erreur: ${failure.message}');
emit(state.copyWith(
conversationsStatus: ConversationsStatus.error,
errorMessage: failure.message,
));
},
(conversations) {
_log('${conversations.length} conversations chargées');
emit(state.copyWith(
conversationsStatus: ConversationsStatus.success,
conversations: conversations,
));
},
);
}
/// Charge les messages d'une conversation.
Future<void> _onLoadMessages(
LoadMessages event,
Emitter<ChatState> emit,
) async {
_log('Chargement des messages de ${event.conversationId}');
emit(state.copyWith(messagesStatus: MessagesStatus.loading));
final result = await chatRepository.getMessages(
event.conversationId,
page: event.page,
size: event.size,
);
result.fold(
(failure) {
_log('Erreur: ${failure.message}');
emit(state.copyWith(
messagesStatus: MessagesStatus.error,
errorMessage: failure.message,
));
},
(messages) {
_log('${messages.length} messages chargés');
emit(state.copyWith(
messagesStatus: MessagesStatus.success,
messages: messages,
currentConversationId: event.conversationId,
));
},
);
}
/// Envoie un nouveau message.
Future<void> _onSendMessage(
SendMessage event,
Emitter<ChatState> emit,
) async {
_log('Envoi d\'un message');
// 🚀 OPTIMISTIC UI: Créer un message temporaire IMMÉDIATEMENT
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final tempMessage = ChatMessage(
id: tempId,
conversationId: state.currentConversationId ?? '',
senderId: event.senderId,
senderFirstName: '', // Sera mis à jour par le serveur
senderLastName: '',
senderProfileImageUrl: '',
content: event.content,
timestamp: DateTime.now(),
isRead: false,
isDelivered: false, // Pas encore délivré
attachmentUrl: event.mediaUrl ?? '',
attachmentType: event.messageType == 'image' ? AttachmentType.image :
event.messageType == 'video' ? AttachmentType.video :
event.messageType == 'audio' ? AttachmentType.audio :
event.messageType == 'file' ? AttachmentType.file :
AttachmentType.none,
);
AppLogger.d('Message optimiste créé avec ID temporaire: $tempId', tag: 'ChatBloc');
// Ajouter immédiatement à la liste (avant la requête HTTP)
final optimisticMessages = List<ChatMessage>.from(state.messages);
optimisticMessages.insert(0, tempMessage);
emit(state.copyWith(
sendMessageStatus: SendMessageStatus.sending,
messages: optimisticMessages,
));
// Maintenant envoyer au serveur
final result = await chatRepository.sendMessage(
senderId: event.senderId,
recipientId: event.recipientId,
content: event.content,
messageType: event.messageType,
mediaUrl: event.mediaUrl,
);
result.fold(
(failure) {
_log('Erreur: ${failure.message}');
AppLogger.e('Échec envoi - Retrait du message temporaire', tag: 'ChatBloc');
// Retirer le message temporaire en cas d'erreur
final revertedMessages = state.messages
.where((msg) => msg.id != tempId)
.toList();
emit(state.copyWith(
sendMessageStatus: SendMessageStatus.error,
errorMessage: failure.message,
messages: revertedMessages,
));
},
(message) {
_log('Message envoyé avec succès');
AppLogger.d('Message envoyé - ID réel: ${message.id}, isDelivered: ${message.isDelivered}, isRead: ${message.isRead}', tag: 'ChatBloc');
// Remplacer le message temporaire par le vrai message du serveur
final updatedMessages = state.messages.map((msg) {
if (msg.id == tempId) {
AppLogger.d('Remplacement du message temporaire par le message réel', tag: 'ChatBloc');
return message;
}
return msg;
}).toList();
emit(state.copyWith(
sendMessageStatus: SendMessageStatus.success,
messages: updatedMessages,
));
},
);
}
/// Marque un message comme lu.
Future<void> _onMarkMessageAsRead(
MarkMessageAsRead event,
Emitter<ChatState> emit,
) async {
_log('Marquage du message ${event.messageId} comme lu');
final result = await chatRepository.markMessageAsRead(event.messageId);
result.fold(
(failure) => _log('Erreur: ${failure.message}'),
(_) => _log('Message marqué comme lu'),
);
}
/// Marque un message comme délivré (reçu par le destinataire).
void _onMarkMessageAsDelivered(
MarkMessageAsDelivered event,
Emitter<ChatState> emit,
) {
_log('Marquage du message ${event.messageId} comme délivré');
AppLogger.d('_onMarkMessageAsDelivered appelé pour ${event.messageId}', tag: 'ChatBloc');
AppLogger.d('Nombre de messages actuels: ${state.messages.length}', tag: 'ChatBloc');
// Mettre à jour le message dans la liste des messages
final updatedMessages = state.messages.map((msg) {
if (msg.id == event.messageId) {
AppLogger.d('Message trouvé! Avant: isDelivered=${msg.isDelivered}, Après: isDelivered=true', tag: 'ChatBloc');
return msg.copyWith(isDelivered: true);
}
return msg;
}).toList();
AppLogger.d('Émission du nouveau state avec ${updatedMessages.length} messages', tag: 'ChatBloc');
emit(state.copyWith(messages: updatedMessages));
}
/// Marque tous les messages d'une conversation comme lus.
Future<void> _onMarkConversationAsRead(
MarkConversationAsRead event,
Emitter<ChatState> emit,
) async {
_log('Marquage de la conversation ${event.conversationId} comme lue');
final result = await chatRepository.markConversationAsRead(
event.conversationId,
event.userId,
);
result.fold(
(failure) => _log('Erreur: ${failure.message}'),
(_) {
_log('Conversation marquée comme lue');
// Mettre à jour le compteur de non lus dans la conversation
final updatedConversations = state.conversations.map((conv) {
if (conv.id == event.conversationId) {
return Conversation(
id: conv.id,
participantId: conv.participantId,
participantFirstName: conv.participantFirstName,
participantLastName: conv.participantLastName,
participantProfileImageUrl: conv.participantProfileImageUrl,
lastMessage: conv.lastMessage,
lastMessageTimestamp: conv.lastMessageTimestamp,
unreadCount: 0,
isTyping: conv.isTyping,
);
}
return conv;
}).toList();
emit(state.copyWith(conversations: updatedConversations));
},
);
}
/// Supprime un message.
Future<void> _onDeleteMessage(
DeleteMessage event,
Emitter<ChatState> emit,
) async {
_log('Suppression du message ${event.messageId}');
final result = await chatRepository.deleteMessage(event.messageId);
result.fold(
(failure) {
_log('Erreur: ${failure.message}');
emit(state.copyWith(errorMessage: failure.message));
},
(_) {
_log('Message supprimé');
// Retirer le message de la liste
final updatedMessages = state.messages
.where((msg) => msg.id != event.messageId)
.toList();
emit(state.copyWith(messages: updatedMessages));
},
);
}
/// Supprime une conversation.
Future<void> _onDeleteConversation(
DeleteConversation event,
Emitter<ChatState> emit,
) async {
_log('Suppression de la conversation ${event.conversationId}');
final result = await chatRepository.deleteConversation(event.conversationId);
result.fold(
(failure) {
_log('Erreur: ${failure.message}');
emit(state.copyWith(errorMessage: failure.message));
},
(_) {
_log('Conversation supprimée');
// Retirer la conversation de la liste
final updatedConversations = state.conversations
.where((conv) => conv.id != event.conversationId)
.toList();
emit(state.copyWith(conversations: updatedConversations));
},
);
}
/// Charge le nombre de messages non lus.
Future<void> _onLoadUnreadCount(
LoadUnreadCount event,
Emitter<ChatState> emit,
) async {
_log('Chargement du nombre de messages non lus');
final result = await chatRepository.getUnreadMessagesCount(event.userId);
result.fold(
(failure) => _log('Erreur: ${failure.message}'),
(count) {
_log('$count messages non lus');
emit(state.copyWith(unreadCount: count));
},
);
}
/// Ajoute un message reçu via WebSocket à la conversation.
void _onAddMessageToConversation(
AddMessageToConversation event,
Emitter<ChatState> emit,
) {
_log('Ajout d\'un message reçu via WebSocket');
final updatedMessages = List<ChatMessage>.from(state.messages);
updatedMessages.insert(0, event.message);
emit(state.copyWith(messages: updatedMessages));
}
/// Met à jour le statut de frappe d'un utilisateur.
void _onUpdateTypingStatus(
UpdateTypingStatus event,
Emitter<ChatState> emit,
) {
_log('Mise à jour du statut de frappe pour ${event.conversationId}');
final updatedConversations = state.conversations.map((conv) {
if (conv.id == event.conversationId) {
return Conversation(
id: conv.id,
participantId: conv.participantId,
participantFirstName: conv.participantFirstName,
participantLastName: conv.participantLastName,
participantProfileImageUrl: conv.participantProfileImageUrl,
lastMessage: conv.lastMessage,
lastMessageTimestamp: conv.lastMessageTimestamp,
unreadCount: conv.unreadCount,
isTyping: event.isTyping,
);
}
return conv;
}).toList();
emit(state.copyWith(conversations: updatedConversations));
}
}
// ============================================================================
// ÉVÉNEMENTS
// ============================================================================
/// Classe de base pour tous les événements du chat.
abstract class ChatEvent extends Equatable {
const ChatEvent();
@override
List<Object?> get props => [];
}
/// Événement pour charger les conversations d'un utilisateur.
class LoadConversations extends ChatEvent {
const LoadConversations(this.userId);
final String userId;
@override
List<Object?> get props => [userId];
}
/// Événement pour charger les messages d'une conversation.
class LoadMessages extends ChatEvent {
const LoadMessages({
required this.conversationId,
this.page = 0,
this.size = 50,
});
final String conversationId;
final int page;
final int size;
@override
List<Object?> get props => [conversationId, page, size];
}
/// Événement pour envoyer un message.
class SendMessage extends ChatEvent {
const SendMessage({
required this.senderId,
required this.recipientId,
required this.content,
this.messageType,
this.mediaUrl,
});
final String senderId;
final String recipientId;
final String content;
final String? messageType;
final String? mediaUrl;
@override
List<Object?> get props => [senderId, recipientId, content, messageType, mediaUrl];
}
/// Événement pour marquer un message comme lu.
class MarkMessageAsRead extends ChatEvent {
const MarkMessageAsRead(this.messageId);
final String messageId;
@override
List<Object?> get props => [messageId];
}
/// Événement pour marquer tous les messages d'une conversation comme lus.
class MarkConversationAsRead extends ChatEvent {
const MarkConversationAsRead({
required this.conversationId,
required this.userId,
});
final String conversationId;
final String userId;
@override
List<Object?> get props => [conversationId, userId];
}
/// Événement pour supprimer un message.
class DeleteMessage extends ChatEvent {
const DeleteMessage(this.messageId);
final String messageId;
@override
List<Object?> get props => [messageId];
}
/// Événement pour supprimer une conversation.
class DeleteConversation extends ChatEvent {
const DeleteConversation(this.conversationId);
final String conversationId;
@override
List<Object?> get props => [conversationId];
}
/// Événement pour charger le nombre de messages non lus.
class LoadUnreadCount extends ChatEvent {
const LoadUnreadCount(this.userId);
final String userId;
@override
List<Object?> get props => [userId];
}
/// Événement pour ajouter un message reçu via WebSocket.
class AddMessageToConversation extends ChatEvent {
const AddMessageToConversation(this.message);
final ChatMessage message;
@override
List<Object?> get props => [message];
}
/// Événement pour marquer un message comme délivré.
class MarkMessageAsDelivered extends ChatEvent {
const MarkMessageAsDelivered(this.messageId);
final String messageId;
@override
List<Object?> get props => [messageId];
}
/// Événement pour mettre à jour le statut de frappe.
class UpdateTypingStatus extends ChatEvent {
const UpdateTypingStatus({
required this.conversationId,
required this.isTyping,
});
final String conversationId;
final bool isTyping;
@override
List<Object?> get props => [conversationId, isTyping];
}
// ============================================================================
// ÉTATS
// ============================================================================
/// Énumération des statuts de chargement des conversations.
enum ConversationsStatus { initial, loading, success, error }
/// Énumération des statuts de chargement des messages.
enum MessagesStatus { initial, loading, success, error }
/// Énumération des statuts d'envoi de message.
enum SendMessageStatus { initial, sending, success, error }
/// État du ChatBloc.
class ChatState extends Equatable {
const ChatState({
this.conversationsStatus = ConversationsStatus.initial,
this.messagesStatus = MessagesStatus.initial,
this.sendMessageStatus = SendMessageStatus.initial,
this.conversations = const [],
this.messages = const [],
this.currentConversationId,
this.unreadCount = 0,
this.errorMessage,
});
final ConversationsStatus conversationsStatus;
final MessagesStatus messagesStatus;
final SendMessageStatus sendMessageStatus;
final List<Conversation> conversations;
final List<ChatMessage> messages;
final String? currentConversationId;
final int unreadCount;
final String? errorMessage;
ChatState copyWith({
ConversationsStatus? conversationsStatus,
MessagesStatus? messagesStatus,
SendMessageStatus? sendMessageStatus,
List<Conversation>? conversations,
List<ChatMessage>? messages,
String? currentConversationId,
int? unreadCount,
String? errorMessage,
}) {
return ChatState(
conversationsStatus: conversationsStatus ?? this.conversationsStatus,
messagesStatus: messagesStatus ?? this.messagesStatus,
sendMessageStatus: sendMessageStatus ?? this.sendMessageStatus,
conversations: conversations ?? this.conversations,
messages: messages ?? this.messages,
currentConversationId: currentConversationId ?? this.currentConversationId,
unreadCount: unreadCount ?? this.unreadCount,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [
conversationsStatus,
messagesStatus,
sendMessageStatus,
conversations,
messages,
currentConversationId,
unreadCount,
errorMessage,
];
}