2026-03-15 00:28:04 +05:30
|
|
|
import 'dart:async';
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:logger/logger.dart';
|
|
|
|
|
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
|
|
|
|
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
|
|
|
|
|
import 'package:real_estate_mobile/features/messaging/data/messaging_repository.dart';
|
2026-03-15 00:28:04 +05:30
|
|
|
import 'package:real_estate_mobile/features/messaging/data/socket_service.dart';
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
|
|
|
|
|
|
|
|
|
|
class MessagingState {
|
|
|
|
|
final List<Conversation> conversations;
|
|
|
|
|
final Map<String, List<ChatMessage>> messages;
|
|
|
|
|
final Map<String, PaginationInfo> pagination;
|
|
|
|
|
final Set<String> typingUsers;
|
|
|
|
|
final bool isLoadingConversations;
|
|
|
|
|
final bool isLoadingMessages;
|
|
|
|
|
final bool isSending;
|
|
|
|
|
final String? error;
|
|
|
|
|
final String? activeConversationId;
|
|
|
|
|
final int unreadCount;
|
|
|
|
|
|
|
|
|
|
const MessagingState({
|
|
|
|
|
this.conversations = const [],
|
|
|
|
|
this.messages = const {},
|
|
|
|
|
this.pagination = const {},
|
|
|
|
|
this.typingUsers = const {},
|
|
|
|
|
this.isLoadingConversations = false,
|
|
|
|
|
this.isLoadingMessages = false,
|
|
|
|
|
this.isSending = false,
|
|
|
|
|
this.error,
|
|
|
|
|
this.activeConversationId,
|
|
|
|
|
this.unreadCount = 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
MessagingState copyWith({
|
|
|
|
|
List<Conversation>? conversations,
|
|
|
|
|
Map<String, List<ChatMessage>>? messages,
|
|
|
|
|
Map<String, PaginationInfo>? pagination,
|
|
|
|
|
Set<String>? typingUsers,
|
|
|
|
|
bool? isLoadingConversations,
|
|
|
|
|
bool? isLoadingMessages,
|
|
|
|
|
bool? isSending,
|
|
|
|
|
String? error,
|
|
|
|
|
String? activeConversationId,
|
|
|
|
|
int? unreadCount,
|
|
|
|
|
bool clearError = false,
|
|
|
|
|
bool clearActiveConversation = false,
|
|
|
|
|
}) {
|
|
|
|
|
return MessagingState(
|
|
|
|
|
conversations: conversations ?? this.conversations,
|
|
|
|
|
messages: messages ?? this.messages,
|
|
|
|
|
pagination: pagination ?? this.pagination,
|
|
|
|
|
typingUsers: typingUsers ?? this.typingUsers,
|
|
|
|
|
isLoadingConversations:
|
|
|
|
|
isLoadingConversations ?? this.isLoadingConversations,
|
|
|
|
|
isLoadingMessages: isLoadingMessages ?? this.isLoadingMessages,
|
|
|
|
|
isSending: isSending ?? this.isSending,
|
|
|
|
|
error: clearError ? null : (error ?? this.error),
|
|
|
|
|
activeConversationId: clearActiveConversation
|
|
|
|
|
? null
|
|
|
|
|
: (activeConversationId ?? this.activeConversationId),
|
|
|
|
|
unreadCount: unreadCount ?? this.unreadCount,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class MessagingNotifier extends StateNotifier<MessagingState> {
|
|
|
|
|
final MessagingRepository _repository;
|
|
|
|
|
final Ref _ref;
|
2026-03-15 00:28:04 +05:30
|
|
|
final SocketService _socket = SocketService();
|
|
|
|
|
final List<StreamSubscription> _subscriptions = [];
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
MessagingNotifier(this._repository, this._ref)
|
2026-03-15 00:28:04 +05:30
|
|
|
: super(const MessagingState()) {
|
|
|
|
|
_initSocketListeners();
|
|
|
|
|
}
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
String? get _currentUserId => _ref.read(authProvider).user?.id;
|
|
|
|
|
|
2026-03-15 00:28:04 +05:30
|
|
|
void _initSocketListeners() {
|
|
|
|
|
// Online/offline status changes
|
|
|
|
|
_subscriptions.add(_socket.onStatusChange.listen((data) {
|
|
|
|
|
final userId = data['userId'] as String?;
|
|
|
|
|
final isOnline = data['isOnline'] as bool? ?? false;
|
|
|
|
|
final lastSeenAt = data['lastSeenAt'] as String?;
|
|
|
|
|
if (userId != null) {
|
|
|
|
|
updateUserStatus(userId, isOnline, lastSeenAt: lastSeenAt);
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Incoming messages from other users
|
|
|
|
|
_subscriptions.add(_socket.onNewMessage.listen((message) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
final convId = message.conversationId;
|
|
|
|
|
final currentMessages =
|
|
|
|
|
List<ChatMessage>.from(state.messages[convId] ?? []);
|
|
|
|
|
|
|
|
|
|
// Avoid duplicates
|
|
|
|
|
if (currentMessages.any((m) => m.id == message.id)) return;
|
|
|
|
|
|
|
|
|
|
currentMessages.insert(0, message);
|
|
|
|
|
final updatedMessages =
|
|
|
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
|
|
|
..[convId] = currentMessages;
|
|
|
|
|
|
|
|
|
|
// Update conversation's last message and unread count
|
|
|
|
|
final updatedConversations = state.conversations.map((c) {
|
|
|
|
|
if (c.id == convId) {
|
|
|
|
|
final isActive = state.activeConversationId == convId;
|
|
|
|
|
return c.copyWith(
|
|
|
|
|
lastMessageText: message.content,
|
|
|
|
|
lastMessageAt: message.createdAt,
|
|
|
|
|
unreadCount: isActive ? c.unreadCount : c.unreadCount + 1,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return c;
|
|
|
|
|
}).toList();
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
messages: updatedMessages,
|
|
|
|
|
conversations: updatedConversations,
|
|
|
|
|
);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Typing indicators
|
|
|
|
|
_subscriptions.add(_socket.onTypingStart.listen((data) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
final userId = data['userId'] as String?;
|
|
|
|
|
if (userId != null && userId != _currentUserId) {
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
typingUsers: {...state.typingUsers, userId},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
_subscriptions.add(_socket.onTypingStop.listen((data) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
final userId = data['userId'] as String?;
|
|
|
|
|
if (userId != null) {
|
|
|
|
|
final updated = Set<String>.from(state.typingUsers)..remove(userId);
|
|
|
|
|
state = state.copyWith(typingUsers: updated);
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Read receipts
|
|
|
|
|
_subscriptions.add(_socket.onMessagesRead.listen((data) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
final convId = data['conversationId'] as String?;
|
|
|
|
|
if (convId == null) return;
|
|
|
|
|
final updatedConversations = state.conversations.map((c) {
|
|
|
|
|
if (c.id == convId) return c.copyWith(unreadCount: 0);
|
|
|
|
|
return c;
|
|
|
|
|
}).toList();
|
|
|
|
|
state = state.copyWith(conversations: updatedConversations);
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Update online status for a user across all conversations (matching web)
|
|
|
|
|
void updateUserStatus(String userId, bool isOnline, {String? lastSeenAt}) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
final updatedConversations = state.conversations.map((c) {
|
|
|
|
|
if (c.otherParty.userId == userId || c.otherParty.id == userId) {
|
|
|
|
|
return c.copyWith(
|
|
|
|
|
otherParty: c.otherParty.copyWith(
|
|
|
|
|
isOnline: isOnline,
|
|
|
|
|
lastSeenAt: lastSeenAt,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return c;
|
|
|
|
|
}).toList();
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(conversations: updatedConversations);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
for (final sub in _subscriptions) {
|
|
|
|
|
sub.cancel();
|
|
|
|
|
}
|
|
|
|
|
_subscriptions.clear();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
Future<void> loadConversations() async {
|
|
|
|
|
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
|
|
|
|
try {
|
|
|
|
|
final result = await _repository.getConversations();
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
conversations: result,
|
|
|
|
|
isLoadingConversations: false,
|
|
|
|
|
);
|
|
|
|
|
} catch (e, stack) {
|
|
|
|
|
_log.e('Failed to load conversations', error: e, stackTrace: stack);
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
isLoadingConversations: false,
|
|
|
|
|
error: 'Failed to load conversations',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> loadMessages(
|
|
|
|
|
String conversationId, {
|
|
|
|
|
bool loadMore = false,
|
|
|
|
|
}) async {
|
|
|
|
|
if (loadMore) {
|
|
|
|
|
final existing = state.pagination[conversationId];
|
|
|
|
|
if (existing != null && !existing.hasMore) return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(isLoadingMessages: true, clearError: true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final nextPage =
|
|
|
|
|
loadMore ? ((state.pagination[conversationId]?.page ?? 0) + 1) : 1;
|
|
|
|
|
|
|
|
|
|
final result = await _repository.getMessages(
|
|
|
|
|
conversationId,
|
|
|
|
|
page: nextPage,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final newMessages = result.messages;
|
|
|
|
|
final paginationInfo = result.pagination;
|
|
|
|
|
|
|
|
|
|
final existingMessages =
|
|
|
|
|
loadMore ? (state.messages[conversationId] ?? []) : <ChatMessage>[];
|
|
|
|
|
final mergedMessages =
|
|
|
|
|
loadMore ? [...existingMessages, ...newMessages] : newMessages;
|
|
|
|
|
|
|
|
|
|
final updatedMessages =
|
|
|
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
|
|
|
..[conversationId] = mergedMessages;
|
|
|
|
|
|
|
|
|
|
final updatedPagination =
|
|
|
|
|
Map<String, PaginationInfo>.from(state.pagination)
|
|
|
|
|
..[conversationId] = paginationInfo;
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
messages: updatedMessages,
|
|
|
|
|
pagination: updatedPagination,
|
|
|
|
|
isLoadingMessages: false,
|
|
|
|
|
);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
isLoadingMessages: false,
|
|
|
|
|
error: 'Failed to load messages',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> sendMessage(String conversationId, String content) async {
|
|
|
|
|
final currentUserId = _currentUserId;
|
|
|
|
|
if (currentUserId == null) return;
|
|
|
|
|
|
|
|
|
|
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
|
|
|
|
final now = DateTime.now().toIso8601String();
|
|
|
|
|
|
|
|
|
|
final tempMessage = ChatMessage(
|
|
|
|
|
id: tempId,
|
|
|
|
|
conversationId: conversationId,
|
|
|
|
|
senderId: currentUserId,
|
|
|
|
|
content: content,
|
|
|
|
|
messageType: MessageType.text,
|
|
|
|
|
status: MessageStatus.sent,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
sender: MessageSender(
|
|
|
|
|
id: currentUserId,
|
|
|
|
|
role: 'user',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Add optimistic message at the beginning (newest-first)
|
|
|
|
|
final currentMessages =
|
|
|
|
|
List<ChatMessage>.from(state.messages[conversationId] ?? []);
|
|
|
|
|
currentMessages.insert(0, tempMessage);
|
|
|
|
|
|
|
|
|
|
final updatedMessages =
|
|
|
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
|
|
|
..[conversationId] = currentMessages;
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
messages: updatedMessages,
|
|
|
|
|
isSending: true,
|
|
|
|
|
clearError: true,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final realMessage = await _repository.sendMessage(
|
|
|
|
|
conversationId,
|
|
|
|
|
content: content,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final messagesAfterSend =
|
|
|
|
|
List<ChatMessage>.from(state.messages[conversationId] ?? []);
|
|
|
|
|
final tempIndex = messagesAfterSend.indexWhere((m) => m.id == tempId);
|
|
|
|
|
if (tempIndex != -1) {
|
|
|
|
|
messagesAfterSend[tempIndex] = realMessage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final finalMessages =
|
|
|
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
|
|
|
..[conversationId] = messagesAfterSend;
|
|
|
|
|
|
|
|
|
|
// Update conversation's last message
|
|
|
|
|
final updatedConversations = state.conversations.map((c) {
|
|
|
|
|
if (c.id == conversationId) {
|
|
|
|
|
return c.copyWith(
|
|
|
|
|
lastMessageText: realMessage.content,
|
|
|
|
|
lastMessageAt: realMessage.createdAt,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return c;
|
|
|
|
|
}).toList();
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
messages: finalMessages,
|
|
|
|
|
conversations: updatedConversations,
|
|
|
|
|
isSending: false,
|
|
|
|
|
);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
final messagesAfterError =
|
|
|
|
|
List<ChatMessage>.from(state.messages[conversationId] ?? []);
|
|
|
|
|
messagesAfterError.removeWhere((m) => m.id == tempId);
|
|
|
|
|
|
|
|
|
|
final errorMessages =
|
|
|
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
|
|
|
..[conversationId] = messagesAfterError;
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
messages: errorMessages,
|
|
|
|
|
isSending: false,
|
|
|
|
|
error: 'Failed to send message',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> markAsRead(String conversationId) async {
|
|
|
|
|
try {
|
|
|
|
|
await _repository.markAsRead(conversationId);
|
|
|
|
|
|
|
|
|
|
final updatedConversations = state.conversations.map((c) {
|
|
|
|
|
if (c.id == conversationId) {
|
|
|
|
|
return c.copyWith(unreadCount: 0);
|
|
|
|
|
}
|
|
|
|
|
return c;
|
|
|
|
|
}).toList();
|
|
|
|
|
|
|
|
|
|
final totalUnread = updatedConversations.fold<int>(
|
|
|
|
|
0,
|
|
|
|
|
(sum, c) => sum + c.unreadCount,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
conversations: updatedConversations,
|
|
|
|
|
unreadCount: totalUnread,
|
|
|
|
|
);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void setActiveConversation(String? conversationId) {
|
|
|
|
|
if (conversationId == null) {
|
|
|
|
|
state = state.copyWith(clearActiveConversation: true);
|
|
|
|
|
} else {
|
|
|
|
|
state = state.copyWith(activeConversationId: conversationId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> loadUnreadCount() async {
|
|
|
|
|
try {
|
|
|
|
|
final count = await _repository.getUnreadCount();
|
|
|
|
|
state = state.copyWith(unreadCount: count);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> startConversation(String agentProfileId) async {
|
2026-03-15 00:28:04 +05:30
|
|
|
if (!mounted) return null;
|
2026-03-08 00:41:44 +05:30
|
|
|
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
|
|
|
|
try {
|
|
|
|
|
final conversation =
|
|
|
|
|
await _repository.startConversation(agentProfileId);
|
|
|
|
|
|
2026-03-15 00:28:04 +05:30
|
|
|
if (!mounted) return conversation.id;
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
final exists = state.conversations.any((c) => c.id == conversation.id);
|
|
|
|
|
final updatedConversations = exists
|
|
|
|
|
? state.conversations
|
|
|
|
|
: [conversation, ...state.conversations];
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
conversations: updatedConversations,
|
|
|
|
|
isLoadingConversations: false,
|
|
|
|
|
);
|
|
|
|
|
return conversation.id;
|
|
|
|
|
} catch (_) {
|
2026-03-15 00:28:04 +05:30
|
|
|
if (mounted) {
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
isLoadingConversations: false,
|
|
|
|
|
error: 'Failed to start conversation',
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-08 00:41:44 +05:30
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final messagingProvider =
|
|
|
|
|
StateNotifierProvider<MessagingNotifier, MessagingState>((ref) {
|
|
|
|
|
return MessagingNotifier(ref.watch(messagingRepositoryProvider), ref);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final currentUserIdProvider = Provider<String?>((ref) {
|
|
|
|
|
return ref.watch(authProvider).user?.id;
|
|
|
|
|
});
|