516 lines
19 KiB
Dart
516 lines
19 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;
|
|
StreamSubscription? _statusSubscription;
|
|
StreamSubscription? _readReceiptSubscription;
|
|
Timer? _refreshTimer;
|
|
Timer? _retryTimer;
|
|
String? _currentUserId;
|
|
final Set<String> _sendingIds = {};
|
|
|
|
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<ChatEventUserStatusChanged>(_onUserStatusChanged);
|
|
on<ChatEventRefreshPresence>(_onRefreshPresence);
|
|
on<ChatEventSendTypingStatus>(_onSendTypingStatus);
|
|
on<ChatEventMarkAsRead>(_onMarkAsRead);
|
|
on<ChatEventReadReceiptReceived>(_onReadReceiptReceived);
|
|
on<ChatEventMessageStatusUpdated>(_onMessageStatusUpdated);
|
|
on<ChatEventMessageReplaced>(_onMessageReplaced);
|
|
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']));
|
|
});
|
|
|
|
_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<void> _onStarted(ChatEventStarted event, Emitter<ChatState> 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<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: () => [],
|
|
);
|
|
|
|
final List<Message> mutableList = List<Message>.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<void> _onMessageSent(ChatEventMessageSent event, Emitter<ChatState> 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<Message> 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<Message>.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<Message> currentList = state.maybeMap(
|
|
messagesLoaded: (s) => s.messages,
|
|
orElse: () => updatedMessages,
|
|
);
|
|
|
|
final List<Message> mutableList = List<Message>.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<Message> currentList = state.maybeMap(
|
|
messagesLoaded: (s) => s.messages,
|
|
orElse: () => updatedMessages,
|
|
);
|
|
|
|
final List<Message> mutableList = List<Message>.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<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> _onUserStatusChanged(ChatEventUserStatusChanged event, Emitter<ChatState> 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<void> _onRefreshPresence(ChatEventRefreshPresence event, Emitter<ChatState> 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<void> _onSendTypingStatus(ChatEventSendTypingStatus event, Emitter<ChatState> emit) async {
|
|
await chatRepository.sendTypingStatus(event.chatId, event.isTyping);
|
|
}
|
|
|
|
Future<void> _onMarkAsRead(ChatEventMarkAsRead event, Emitter<ChatState> emit) async {
|
|
await chatRepository.markAsRead(event.chatId, event.messageId, event.sequenceId);
|
|
}
|
|
|
|
Future<void> _onReadReceiptReceived(ChatEventReadReceiptReceived event, Emitter<ChatState> 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<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());
|
|
}
|
|
|
|
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<void> _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<void> _onMessageStatusUpdated(ChatEventMessageStatusUpdated event, Emitter<ChatState> emit) async {
|
|
state.mapOrNull(
|
|
messagesLoaded: (s) {
|
|
final List<Message> mutableList = List<Message>.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<void> _onMessageReplaced(ChatEventMessageReplaced event, Emitter<ChatState> emit) async {
|
|
state.mapOrNull(
|
|
messagesLoaded: (s) {
|
|
final List<Message> mutableList = List<Message>.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<void> close() {
|
|
_messageSubscription?.cancel();
|
|
_typingSubscription?.cancel();
|
|
_statusSubscription?.cancel();
|
|
_readReceiptSubscription?.cancel();
|
|
_refreshTimer?.cancel();
|
|
_retryTimer?.cancel();
|
|
chatRepository.disposeSignalR();
|
|
return super.close();
|
|
}
|
|
}
|