Files
mobile-app/lib/features/messaging/presentation/providers/messaging_provider.dart

417 lines
13 KiB
Dart
Raw Normal View History

import 'dart:async';
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';
import 'package:real_estate_mobile/features/messaging/data/socket_service.dart';
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;
final SocketService _socket = SocketService();
final List<StreamSubscription> _subscriptions = [];
MessagingNotifier(this._repository, this._ref)
: super(const MessagingState()) {
_initSocketListeners();
}
String? get _currentUserId => _ref.read(authProvider).user?.id;
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();
}
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 {
if (!mounted) return null;
state = state.copyWith(isLoadingConversations: true, clearError: true);
try {
final conversation =
await _repository.startConversation(agentProfileId);
if (!mounted) return conversation.id;
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 (_) {
if (mounted) {
state = state.copyWith(
isLoadingConversations: false,
error: 'Failed to start conversation',
);
}
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;
});