import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../domain/entities/chat.dart'; import '../../domain/entities/message.dart'; import '../../domain/repositories/chat_repository.dart'; import 'chat_event.dart'; import 'chat_state.dart'; class ChatBloc extends Bloc { final ChatRepository chatRepository; StreamSubscription? _messageSubscription; StreamSubscription? _typingSubscription; StreamSubscription? _statusSubscription; StreamSubscription? _readReceiptSubscription; Timer? _refreshTimer; Timer? _retryTimer; String? _currentUserId; final Set _sendingIds = {}; ChatBloc({required this.chatRepository}) : super(const ChatState.initial()) { on(_onStarted); on(_onChatsLoaded); on(_onChatSelected); on(_onMessagesRequested); on(_onMessageReceived); on(_onMessageSent); on(_onFavoritesRequested); on(_onTypingUpdated); on(_onUserStatusChanged); on(_onRefreshPresence); on(_onSendTypingStatus); on(_onMarkAsRead); on(_onReadReceiptReceived); on(_onMessageStatusUpdated); on(_onMessageReplaced); on(_onCacheCleared); _messageSubscription = chatRepository.messageStream.listen((message) { add(ChatEvent.messageReceived(message)); }); _typingSubscription = chatRepository.typingStream.listen((data) { add(ChatEvent.typingUpdated(data['chatId'], data['userId'], data['isTyping'])); }); _statusSubscription = chatRepository.statusStream.listen((data) { add(ChatEvent.userStatusChanged( data['userId'], data['isOnline'], lastSeen: data['lastSeen'] != null ? DateTime.tryParse(data['lastSeen'].toString()) : null, )); }); _readReceiptSubscription = chatRepository.readReceiptStream.listen((data) { add(ChatEvent.readReceiptReceived( chatId: data['chatId'], userId: data['userId'], lastReadMessageId: data['lastReadMessageId'], lastReadSequenceId: int.tryParse(data['lastReadSequenceId'].toString()) ?? 0, )); }); // Refresh presence status every minute to update "X minutes ago" _refreshTimer = Timer.periodic(const Duration(minutes: 1), (_) { add(const ChatEvent.refreshPresence()); }); // Retry sending messages every 10 seconds _retryTimer = Timer.periodic(const Duration(seconds: 10), (_) { _retryPendingMessages(); }); } Future _onStarted(ChatEventStarted event, Emitter emit) async { _currentUserId = event.userId; // ignore: avoid_print print('[DEBUG] ChatBloc._onStarted for user: ${event.userId}'); // Start SignalR in background chatRepository.initSignalR().catchError((e) { // Log or handle SignalR init error }); // Immediately trigger chats loading add(ChatEvent.chatsLoaded(currentUserId: event.userId)); } Future _onChatsLoaded(ChatEventChatsLoaded event, Emitter emit) async { emit(const ChatState.loading()); final result = await chatRepository.getChats(currentUserId: event.currentUserId); result.fold( (error) => emit(ChatState.error(error.message ?? 'Failed to load chats')), (chats) => emit(ChatState.chatsLoaded(chats)), ); } Future _onChatSelected(ChatEventChatSelected event, Emitter emit) async { final currentChatId = state.maybeMap( chatSelected: (s) => s.chat.id, messagesLoaded: (s) => s.chat.id, orElse: () => null, ); if (currentChatId == event.chatId) { // Already in this chat, just refresh messages final messagesResult = await chatRepository.getMessages(event.chatId); messagesResult.fold( (error) => null, (messages) { final currentChat = state.maybeMap( chatSelected: (s) => s.chat, messagesLoaded: (s) => s.chat, orElse: () => null, ); if (currentChat != null) { emit(ChatState.messagesLoaded(currentChat, messages, typingUsers: state.typingUsers)); } }, ); return; } emit(ChatState.loading(typingUsers: state.typingUsers)); final chatResult = await chatRepository.getChatById(event.chatId); final messagesResult = await chatRepository.getMessages(event.chatId); chatResult.fold( (error) => emit(ChatState.error(error.message ?? 'Failed to load chat', typingUsers: state.typingUsers)), (chat) { messagesResult.fold( (error) => emit(ChatState.chatSelected(chat, [], typingUsers: state.typingUsers)), (messages) => emit(ChatState.chatSelected(chat, messages, typingUsers: state.typingUsers)), ); }, ); } Future _onMessagesRequested(ChatEventMessagesRequested event, Emitter emit) async { final currentChat = state.maybeMap( chatSelected: (s) => s.chat, messagesLoaded: (s) => s.chat, orElse: () => null, ); // Only fetch if it's the current chat or we are in chats list final result = await chatRepository.getMessages(event.chatId); result.fold( (error) => null, // Don't emit error state for background updates to avoid UI flickering (messages) { if (currentChat != null && currentChat.id == event.chatId) { // If we are in the chat, update messages and switch to messagesLoaded (stable state) emit(ChatState.messagesLoaded(currentChat, messages, typingUsers: state.typingUsers)); } // If we are in the list, the list will update via local cache anyway or on next fetch }, ); } Future _onMessageReceived(ChatEventMessageReceived event, Emitter emit) async { final message = event.message; final currentChat = state.maybeMap( chatSelected: (s) => s.chat, messagesLoaded: (s) => s.chat, orElse: () => null, ); if (currentChat != null && currentChat.id == message.chatId) { final List currentMessages = state.maybeMap( chatSelected: (s) => s.messages, messagesLoaded: (s) => s.messages, orElse: () => [], ); final List mutableList = List.from(currentMessages); // Check if this is a message from me that should replace a temp one if (message.senderId == _currentUserId) { // Look for a temp message with same content and type final tempIdx = mutableList.indexWhere((m) => m.id.startsWith('temp_') && m.messageType.toLowerCase() == message.messageType.toLowerCase() && (m.content.trim() == message.content.trim() || (m.content.isEmpty && message.content.isEmpty))); if (tempIdx != -1) { // Replace temp with real one // ignore: avoid_print print('[DEBUG] Smart replacement (SignalR): replacing ${mutableList[tempIdx].id} with ${message.id} (Type: ${message.messageType})'); mutableList[tempIdx] = message; emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers)); return; } // If no temp message found, check if we already have this message by ID (prevent double add) if (mutableList.any((m) => m.id == message.id)) { // ignore: avoid_print print('[DEBUG] Self-message already in list, skipping: ${message.id}'); return; } // ignore: avoid_print print('[DEBUG] Self-message received but no matching temp found: ${message.id} (Content: "${message.content}", Type: ${message.messageType})'); } else { // From someone else, check for duplicates if (mutableList.any((m) => m.id == message.id)) return; } final updatedMessages = mutableList..insert(0, message); emit(ChatState.messagesLoaded(currentChat, updatedMessages, typingUsers: state.typingUsers)); } // Also trigger chats refresh if we are in chatsLoaded state to update last message/unread state.maybeMap( chatsLoaded: (_) => add(ChatEvent.chatsLoaded(currentUserId: currentChat?.participants?.firstWhere((p) => p.id != '').id)), // userId logic might need to be better orElse: () => null, ); } Future _onMessageSent(ChatEventMessageSent event, Emitter emit) async { final currentChat = state.maybeMap( chatSelected: (s) => s.chat, messagesLoaded: (s) => s.chat, orElse: () => null, ); if (currentChat == null) return; final userId = _currentUserId ?? ''; final List currentMessages = state.maybeMap( chatSelected: (s) => s.messages, messagesLoaded: (s) => s.messages, orElse: () => [], ); // Create optimistic "sending" message final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; final tempMessage = Message( id: tempId, chatId: event.chatId, senderId: userId, content: event.content, messageType: 'text', sequenceId: -1, // Temporary createdAt: DateTime.now(), status: MessageStatus.sending, ); final updatedMessages = List.from(currentMessages)..insert(0, tempMessage); emit(ChatState.messagesLoaded(currentChat, updatedMessages, typingUsers: state.typingUsers)); final result = await chatRepository.sendMessage(event.chatId, event.content); result.fold( (error) { // ignore: avoid_print print('[DEBUG] sendMessage failed for $tempId: ${error.toString()}'); final isNetworkError = error.maybeMap( network: (_) => true, orElse: () => false, ); if (isNetworkError) { // Keep as sending, retry timer will pick it up return; } final List currentList = state.maybeMap( messagesLoaded: (s) => s.messages, orElse: () => updatedMessages, ); final List mutableList = List.from(currentList); final idx = mutableList.indexWhere((m) => m.id == tempId); if (idx != -1) { mutableList[idx] = mutableList[idx].copyWith(status: MessageStatus.error); emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers)); } }, (sentMessage) { // Replace temp message with the actual one from server // ignore: avoid_print print('[DEBUG] sendMessage success for $tempId, replacing with ${sentMessage.id}'); final List currentList = state.maybeMap( messagesLoaded: (s) => s.messages, orElse: () => updatedMessages, ); final List mutableList = List.from(currentList); final idx = mutableList.indexWhere((m) => m.id == tempId); if (idx != -1) { mutableList[idx] = sentMessage; emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers)); } }, ); } Future _onFavoritesRequested(ChatEventFavoritesRequested event, Emitter emit) async { emit(ChatState.loading(typingUsers: state.typingUsers)); final result = await chatRepository.getOrCreateFavorites(); result.fold( (error) => emit(ChatState.error(error.message ?? 'Failed to open Favorites', typingUsers: state.typingUsers)), (chat) => add(ChatEvent.chatSelected(chat.id)), ); } Future _onTypingUpdated(ChatEventTypingUpdated event, Emitter emit) async { final Map> newTypingUsers = Map.from(state.typingUsers); final Set chatTypingUsers = Set.from(newTypingUsers[event.chatId] ?? {}); if (event.isTyping) { chatTypingUsers.add(event.userId); } else { chatTypingUsers.remove(event.userId); } if (chatTypingUsers.isEmpty) { newTypingUsers.remove(event.chatId); } else { newTypingUsers[event.chatId] = chatTypingUsers; } state.maybeMap( chatSelected: (s) { // If it's the current chat, transition to stable state if (s.chat.id == event.chatId) { emit(ChatState.messagesLoaded(s.chat, s.messages, typingUsers: newTypingUsers)); } else { emit(s.copyWith(typingUsers: newTypingUsers)); } }, messagesLoaded: (s) => emit(s.copyWith(typingUsers: newTypingUsers)), chatsLoaded: (s) => emit(s.copyWith(typingUsers: newTypingUsers)), orElse: () => emit(state.copyWith(typingUsers: newTypingUsers)), ); } Future _onUserStatusChanged(ChatEventUserStatusChanged event, Emitter emit) async { state.mapOrNull( chatsLoaded: (s) { final updatedChats = s.chats.map((chat) { final participants = chat.participants; if (participants != null && participants.any((p) => p.id == event.userId)) { final updatedParticipants = participants.map((p) { if (p.id == event.userId) { return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen); } return p; }).toList(); return chat.copyWith(participants: updatedParticipants); } return chat; }).toList(); emit(s.copyWith(chats: updatedChats)); }, chatSelected: (s) { if (s.chat.participants?.any((p) => p.id == event.userId) ?? false) { final updatedParticipants = s.chat.participants!.map((p) { if (p.id == event.userId) { return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen); } return p; }).toList(); final updatedChat = s.chat.copyWith(participants: updatedParticipants); emit(s.copyWith(chat: updatedChat)); } }, messagesLoaded: (s) { if (s.chat.participants?.any((p) => p.id == event.userId) ?? false) { final updatedParticipants = s.chat.participants!.map((p) { if (p.id == event.userId) { return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen); } return p; }).toList(); final updatedChat = s.chat.copyWith(participants: updatedParticipants); emit(s.copyWith(chat: updatedChat)); } }, ); } Future _onRefreshPresence(ChatEventRefreshPresence event, Emitter emit) async { // Re-emit the current state with a new timestamp to trigger UI rebuild state.mapOrNull( initial: (s) => emit(s.copyWith(lastUpdate: DateTime.now())), loading: (s) => emit(s.copyWith(lastUpdate: DateTime.now())), chatsLoaded: (s) => emit(s.copyWith(lastUpdate: DateTime.now())), chatSelected: (s) => emit(s.copyWith(lastUpdate: DateTime.now())), messagesLoaded: (s) => emit(s.copyWith(lastUpdate: DateTime.now())), error: (s) => emit(s.copyWith(lastUpdate: DateTime.now())), ); } Future _onSendTypingStatus(ChatEventSendTypingStatus event, Emitter emit) async { await chatRepository.sendTypingStatus(event.chatId, event.isTyping); } Future _onMarkAsRead(ChatEventMarkAsRead event, Emitter emit) async { await chatRepository.markAsRead(event.chatId, event.messageId, event.sequenceId); } Future _onReadReceiptReceived(ChatEventReadReceiptReceived event, Emitter emit) async { state.mapOrNull( messagesLoaded: (s) { if (s.chat.id == event.chatId) { final updatedMessages = s.messages.map((m) { if (m.sequenceId <= event.lastReadSequenceId && m.status != MessageStatus.read) { return m.copyWith(status: MessageStatus.read); } return m; }).toList(); emit(s.copyWith(messages: updatedMessages)); } }, chatSelected: (s) { if (s.chat.id == event.chatId) { final updatedMessages = s.messages.map((m) { if (m.sequenceId <= event.lastReadSequenceId && m.status != MessageStatus.read) { return m.copyWith(status: MessageStatus.read); } return m; }).toList(); emit(s.copyWith(messages: updatedMessages)); } }, ); } Future _onCacheCleared(ChatEventCacheCleared event, Emitter emit) async { // ignore: avoid_print print('[DEBUG] ChatBloc._onCacheCleared START'); await chatRepository.clearCache(); // ignore: avoid_print print('[DEBUG] ChatBloc._onCacheCleared END'); emit(const ChatState.initial()); } void _retryPendingMessages() { state.mapOrNull( messagesLoaded: (s) { final pending = s.messages.where((m) => m.status == MessageStatus.sending && m.id.startsWith('temp_')).toList(); for (final msg in pending) { if (!_sendingIds.contains(msg.id)) { _retrySendMessage(msg); } } }, ); } Future _retrySendMessage(Message tempMessage) async { _sendingIds.add(tempMessage.id); final result = await chatRepository.sendMessage(tempMessage.chatId, tempMessage.content); _sendingIds.remove(tempMessage.id); result.fold( (error) { final isNetworkError = error.maybeMap( network: (_) => true, orElse: () => false, ); if (!isNetworkError) { _updateMessageStatus(tempMessage.id, MessageStatus.error); } }, (sentMessage) { _replaceTempMessage(tempMessage.id, sentMessage); }, ); } void _updateMessageStatus(String id, MessageStatus status) { add(ChatEvent.messageStatusUpdated(id, status)); } void _replaceTempMessage(String tempId, Message realMessage) { add(ChatEvent.messageReplaced(tempId, realMessage)); } Future _onMessageStatusUpdated(ChatEventMessageStatusUpdated event, Emitter emit) async { state.mapOrNull( messagesLoaded: (s) { final List mutableList = List.from(s.messages); final idx = mutableList.indexWhere((m) => m.id == event.messageId); if (idx != -1) { mutableList[idx] = mutableList[idx].copyWith(status: event.status); emit(s.copyWith(messages: mutableList)); } }, ); } Future _onMessageReplaced(ChatEventMessageReplaced event, Emitter emit) async { state.mapOrNull( messagesLoaded: (s) { final List mutableList = List.from(s.messages); final idx = mutableList.indexWhere((m) => m.id == event.tempId); if (idx != -1) { mutableList[idx] = event.realMessage; emit(s.copyWith(messages: mutableList)); } }, ); } @override Future close() { _messageSubscription?.cancel(); _typingSubscription?.cancel(); _statusSubscription?.cancel(); _readReceiptSubscription?.cancel(); _refreshTimer?.cancel(); _retryTimer?.cancel(); chatRepository.disposeSignalR(); return super.close(); } }