Files
forkmessager/client-mobile/lib/features/chat/presentation/bloc/chat_bloc.dart
T

220 lines
8.2 KiB
Dart

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<ChatEvent, ChatState> {
final ChatRepository chatRepository;
StreamSubscription? _messageSubscription;
StreamSubscription? _typingSubscription;
ChatBloc({required this.chatRepository}) : super(const ChatState.initial()) {
on<ChatEventStarted>(_onStarted);
on<ChatEventChatsLoaded>(_onChatsLoaded);
on<ChatEventChatSelected>(_onChatSelected);
on<ChatEventMessagesRequested>(_onMessagesRequested);
on<ChatEventMessageReceived>(_onMessageReceived);
on<ChatEventMessageSent>(_onMessageSent);
on<ChatEventFavoritesRequested>(_onFavoritesRequested);
on<ChatEventTypingUpdated>(_onTypingUpdated);
on<ChatEventSendTypingStatus>(_onSendTypingStatus);
on<ChatEventCacheCleared>(_onCacheCleared);
_messageSubscription = chatRepository.messageStream.listen((message) {
add(ChatEvent.messageReceived(message));
});
_typingSubscription = chatRepository.typingStream.listen((data) {
add(ChatEvent.typingUpdated(data['chatId'], data['userId'], data['isTyping']));
});
}
Future<void> _onStarted(ChatEventStarted event, Emitter<ChatState> emit) async {
// 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<void> _onChatsLoaded(ChatEventChatsLoaded event, Emitter<ChatState> 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<void> _onChatSelected(ChatEventChatSelected event, Emitter<ChatState> 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<void> _onMessagesRequested(ChatEventMessagesRequested event, Emitter<ChatState> 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<void> _onMessageReceived(ChatEventMessageReceived event, Emitter<ChatState> 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<Message> currentMessages = state.maybeMap(
chatSelected: (s) => s.messages,
messagesLoaded: (s) => s.messages,
orElse: () => [],
);
// Check for duplicates
if (currentMessages.any((m) => m.id == message.id)) return;
final updatedMessages = List<Message>.from(currentMessages)..insert(0, message);
// Ensure sorting if needed (usually messages are already in order from stream)
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<void> _onMessageSent(ChatEventMessageSent event, Emitter<ChatState> emit) async {
final result = await chatRepository.sendMessage(event.chatId, event.content);
result.fold(
(error) => emit(ChatState.error(error.message ?? 'Failed to send message', typingUsers: state.typingUsers)),
(_) {
// Optimistically or via refresh
add(ChatEvent.messagesRequested(event.chatId));
},
);
}
Future<void> _onFavoritesRequested(ChatEventFavoritesRequested event, Emitter<ChatState> 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<void> _onTypingUpdated(ChatEventTypingUpdated event, Emitter<ChatState> emit) async {
final Map<String, Set<String>> newTypingUsers = Map.from(state.typingUsers);
final Set<String> 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<void> _onSendTypingStatus(ChatEventSendTypingStatus event, Emitter<ChatState> emit) async {
await chatRepository.sendTypingStatus(event.chatId, event.isTyping);
}
Future<void> _onCacheCleared(ChatEventCacheCleared event, Emitter<ChatState> 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());
}
@override
Future<void> close() {
_messageSubscription?.cancel();
_typingSubscription?.cancel();
chatRepository.disposeSignalR();
return super.close();
}
}