759 lines
25 KiB
Dart
759 lines
25 KiB
Dart
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();
|
|
// Defer to avoid modifying provider during widget tree build
|
|
Future.microtask(() => loadUnreadCount());
|
|
}
|
|
|
|
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 (broadcast may arrive after ack for sender's own message)
|
|
if (currentMessages.any((m) => m.id == message.id)) return;
|
|
|
|
// Ignore own messages — the sender path handles these via sendMessage()
|
|
final isMine = message.senderId == _currentUserId;
|
|
if (isMine) return;
|
|
|
|
// Immediately ack delivery to backend so sender's tick goes single → double.
|
|
// This works regardless of Redis presence state and avoids races on send.
|
|
_socket.ackMessageReceived(convId, message.id);
|
|
|
|
currentMessages.insert(0, message);
|
|
final updatedMessages =
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
..[convId] = currentMessages;
|
|
|
|
// Show friendly preview for non-text messages (matching web)
|
|
String previewText = message.content;
|
|
if (message.messageType == MessageType.image) {
|
|
previewText = 'Sent an image';
|
|
} else if (message.messageType == MessageType.file) {
|
|
previewText = 'Sent a file';
|
|
} else if (_isGifUrl(message.content)) {
|
|
previewText = 'GIF';
|
|
}
|
|
|
|
// 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: previewText,
|
|
lastMessageAt: message.createdAt,
|
|
unreadCount: isActive ? c.unreadCount : c.unreadCount + 1,
|
|
);
|
|
}
|
|
return c;
|
|
}).toList();
|
|
|
|
// Recalculate total unread count
|
|
final totalUnread = updatedConversations.fold<int>(
|
|
0, (sum, c) => sum + c.unreadCount,
|
|
);
|
|
|
|
state = state.copyWith(
|
|
messages: updatedMessages,
|
|
conversations: updatedConversations,
|
|
unreadCount: totalUnread,
|
|
);
|
|
|
|
// Auto-mark as read if user is currently viewing this conversation
|
|
final isActive = state.activeConversationId == convId;
|
|
if (isActive) {
|
|
markAsRead(convId);
|
|
}
|
|
}));
|
|
|
|
// 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);
|
|
}
|
|
}));
|
|
|
|
// Delivered receipts — update single tick → double tick
|
|
_subscriptions.add(_socket.onMessageDelivered.listen((data) {
|
|
if (!mounted) return;
|
|
final convId = data['conversationId'] as String?;
|
|
final messageId = data['messageId'] as String?;
|
|
final deliveredAt = data['deliveredAt'] as String?;
|
|
if (convId == null) return;
|
|
|
|
final currentMessages = List<ChatMessage>.from(state.messages[convId] ?? []);
|
|
bool changed = false;
|
|
|
|
final updatedMessages = currentMessages.map((m) {
|
|
// Update specific message or all sent messages in this conversation
|
|
if (messageId != null && m.id == messageId && m.status == MessageStatus.sent) {
|
|
changed = true;
|
|
return m.copyWith(status: MessageStatus.delivered, deliveredAt: deliveredAt);
|
|
}
|
|
// If no specific messageId, mark all sent messages as delivered
|
|
if (messageId == null && m.senderId == _currentUserId && m.status == MessageStatus.sent) {
|
|
changed = true;
|
|
return m.copyWith(status: MessageStatus.delivered, deliveredAt: deliveredAt);
|
|
}
|
|
return m;
|
|
}).toList();
|
|
|
|
if (changed) {
|
|
final newMessages = Map<String, List<ChatMessage>>.from(state.messages)
|
|
..[convId] = updatedMessages;
|
|
state = state.copyWith(messages: newMessages);
|
|
}
|
|
}));
|
|
|
|
// Read receipts — update message ticks and conversation unread count
|
|
_subscriptions.add(_socket.onMessagesRead.listen((data) {
|
|
if (!mounted) return;
|
|
final convId = data['conversationId'] as String?;
|
|
final readAt = data['readAt'] as String?;
|
|
if (convId == null) return;
|
|
|
|
// Update conversation unread count
|
|
final updatedConversations = state.conversations.map((c) {
|
|
if (c.id == convId) return c.copyWith(unreadCount: 0);
|
|
return c;
|
|
}).toList();
|
|
|
|
// Update message statuses — mark current user's sent messages as READ
|
|
final currentMessages = List<ChatMessage>.from(state.messages[convId] ?? []);
|
|
final currentUserId = _currentUserId;
|
|
bool messagesChanged = false;
|
|
final updatedMessages = currentMessages.map((m) {
|
|
if (m.senderId == currentUserId && m.status != MessageStatus.read) {
|
|
messagesChanged = true;
|
|
return m.copyWith(status: MessageStatus.read, readAt: readAt);
|
|
}
|
|
return m;
|
|
}).toList();
|
|
|
|
final newMessages = messagesChanged
|
|
? (Map<String, List<ChatMessage>>.from(state.messages)..[convId] = updatedMessages)
|
|
: state.messages;
|
|
|
|
state = state.copyWith(conversations: updatedConversations, messages: newMessages);
|
|
}));
|
|
|
|
// Conversation deleted by other party — remove from local state
|
|
_subscriptions.add(_socket.onConversationDeleted.listen((data) {
|
|
if (!mounted) return;
|
|
final convId = data['conversationId'] as String?;
|
|
if (convId == null) return;
|
|
|
|
final updatedMessages =
|
|
Map<String, List<ChatMessage>>.from(state.messages)..remove(convId);
|
|
final updatedConversations =
|
|
state.conversations.where((c) => c.id != convId).toList();
|
|
final totalUnread = updatedConversations.fold<int>(
|
|
0, (sum, c) => sum + c.unreadCount,
|
|
);
|
|
state = state.copyWith(
|
|
messages: updatedMessages,
|
|
conversations: updatedConversations,
|
|
unreadCount: totalUnread,
|
|
clearActiveConversation: state.activeConversationId == convId,
|
|
);
|
|
}));
|
|
|
|
// Reconnection: re-join active conversation room and catch up missed messages
|
|
_subscriptions.add(_socket.onConnectionChange.listen((connected) {
|
|
if (!mounted || !connected) return;
|
|
final activeConvId = state.activeConversationId;
|
|
if (activeConvId != null) {
|
|
_rejoinAndCatchUp(activeConvId);
|
|
}
|
|
// Refresh conversation list to catch any missed updates
|
|
loadConversations();
|
|
}));
|
|
}
|
|
|
|
/// Re-join socket room and fetch missed messages after reconnection (matching web)
|
|
Future<void> _rejoinAndCatchUp(String conversationId, {int attempt = 1}) async {
|
|
try {
|
|
await _socket.joinConversation(conversationId);
|
|
} catch (_) {
|
|
if (attempt < 3 && mounted && state.activeConversationId == conversationId) {
|
|
await Future.delayed(Duration(seconds: attempt));
|
|
return _rejoinAndCatchUp(conversationId, attempt: attempt + 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fetch recent messages to catch any missed during disconnect
|
|
if (!mounted || state.activeConversationId != conversationId) return;
|
|
try {
|
|
final result = await _repository.getMessages(conversationId, page: 1);
|
|
final recentMessages = result.messages.reversed.toList();
|
|
final currentMessages = List<ChatMessage>.from(state.messages[conversationId] ?? []);
|
|
final existingIds = currentMessages.map((m) => m.id).toSet();
|
|
final newMessages = recentMessages.where((m) => !existingIds.contains(m.id)).toList();
|
|
|
|
if (newMessages.isNotEmpty && mounted) {
|
|
currentMessages.insertAll(0, newMessages);
|
|
// Sort newest first
|
|
currentMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
final updatedMessages = Map<String, List<ChatMessage>>.from(state.messages)
|
|
..[conversationId] = currentMessages;
|
|
state = state.copyWith(messages: updatedMessages);
|
|
}
|
|
} catch (e) {
|
|
_log.e('Failed to catch up messages after reconnect', error: e);
|
|
}
|
|
}
|
|
|
|
static bool _isGifUrl(String text) {
|
|
final lower = text.trim().toLowerCase();
|
|
return lower.endsWith('.gif') ||
|
|
lower.contains('giphy.com/') ||
|
|
lower.contains('giphy.gif') ||
|
|
lower.contains('tenor.com/');
|
|
}
|
|
|
|
/// 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();
|
|
// Sort: favorites first, then by lastMessageAt
|
|
final currentUserId = _currentUserId;
|
|
result.sort((a, b) {
|
|
final aFav = a.userId == currentUserId ? a.userFavorited : a.agentFavorited;
|
|
final bFav = b.userId == currentUserId ? b.userFavorited : b.agentFavorited;
|
|
if (aFav != bFav) return aFav ? -1 : 1;
|
|
final aTime = a.lastMessageAt ?? '';
|
|
final bTime = b.lastMessageAt ?? '';
|
|
return bTime.compareTo(aTime);
|
|
});
|
|
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,
|
|
);
|
|
|
|
// API returns chronological order (oldest first), but our ListView is
|
|
// reverse: true so we need newest-first order (index 0 = newest).
|
|
final newMessages = result.messages.reversed.toList();
|
|
final paginationInfo = result.pagination;
|
|
|
|
final existingMessages =
|
|
loadMore ? (state.messages[conversationId] ?? []) : <ChatMessage>[];
|
|
// When loading more (older messages), append to end (older = higher index)
|
|
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;
|
|
if (state.isSending) return; // Prevent double-send
|
|
|
|
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 {
|
|
ChatMessage? realMessage;
|
|
|
|
// Try socket first (enables real-time delivery to other user)
|
|
if (_socket.isConnected) {
|
|
realMessage = await _socket.sendMessage(conversationId, {
|
|
'content': content,
|
|
'messageType': 'TEXT',
|
|
});
|
|
}
|
|
|
|
// Fallback to REST if socket failed or not connected
|
|
if (realMessage == null) {
|
|
try {
|
|
realMessage = await _repository.sendMessage(
|
|
conversationId,
|
|
content: content,
|
|
);
|
|
} catch (restError) {
|
|
_log.e('REST send also failed', error: restError);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
final messagesAfterSend =
|
|
List<ChatMessage>.from(state.messages[conversationId] ?? []);
|
|
final tempIndex = messagesAfterSend.indexWhere((m) => m.id == tempId);
|
|
if (tempIndex != -1) {
|
|
messagesAfterSend[tempIndex] = realMessage;
|
|
} else {
|
|
// Temp message was removed (e.g., by a reload) — add the real one
|
|
messagesAfterSend.insert(0, realMessage);
|
|
}
|
|
|
|
// Deduplicate — socket broadcast might have already added this message
|
|
final seen = <String>{};
|
|
messagesAfterSend.removeWhere((m) => !seen.add(m.id));
|
|
|
|
final finalMessages =
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
..[conversationId] = messagesAfterSend;
|
|
|
|
// Update conversation's last message with friendly preview
|
|
final sent = realMessage;
|
|
String sentPreview = sent.content;
|
|
if (sent.messageType == MessageType.image) {
|
|
sentPreview = 'Sent an image';
|
|
} else if (sent.messageType == MessageType.file) {
|
|
sentPreview = 'Sent a file';
|
|
} else if (_isGifUrl(sent.content)) {
|
|
sentPreview = 'GIF';
|
|
}
|
|
|
|
final updatedConversations = state.conversations.map((c) {
|
|
if (c.id == conversationId) {
|
|
return c.copyWith(
|
|
lastMessageText: sentPreview,
|
|
lastMessageAt: sent.createdAt,
|
|
);
|
|
}
|
|
return c;
|
|
}).toList();
|
|
|
|
state = state.copyWith(
|
|
messages: finalMessages,
|
|
conversations: updatedConversations,
|
|
isSending: false,
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
|
|
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;
|
|
|
|
final errorMsg = e.toString().contains('connection')
|
|
? 'You must be connected to send messages'
|
|
: 'Failed to send message';
|
|
|
|
state = state.copyWith(
|
|
messages: errorMessages,
|
|
isSending: false,
|
|
error: errorMsg,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> markAsRead(String conversationId) async {
|
|
try {
|
|
await _repository.markAsRead(conversationId);
|
|
if (!mounted) return;
|
|
|
|
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 (!mounted) return;
|
|
if (conversationId == null) {
|
|
state = state.copyWith(clearActiveConversation: true);
|
|
} else {
|
|
state = state.copyWith(activeConversationId: conversationId);
|
|
}
|
|
}
|
|
|
|
Future<void> loadUnreadCount() async {
|
|
try {
|
|
final count = await _repository.getUnreadCount();
|
|
if (mounted) 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;
|
|
}
|
|
}
|
|
|
|
Future<String?> startConversationWithUser(String userId) async {
|
|
if (!mounted) return null;
|
|
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
|
try {
|
|
final conversation =
|
|
await _repository.startConversationWithUser(userId);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
Future<void> clearChat(String conversationId) async {
|
|
try {
|
|
await _repository.clearChat(conversationId);
|
|
// Clear local messages
|
|
final updatedMessages =
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
..[conversationId] = [];
|
|
// Update conversation preview
|
|
final updatedConversations = state.conversations.map((c) {
|
|
if (c.id == conversationId) {
|
|
return c.copyWith(lastMessageText: '', unreadCount: 0);
|
|
}
|
|
return c;
|
|
}).toList();
|
|
state = state.copyWith(
|
|
messages: updatedMessages,
|
|
conversations: updatedConversations,
|
|
);
|
|
} catch (_) {
|
|
state = state.copyWith(error: 'Failed to clear chat');
|
|
}
|
|
}
|
|
|
|
Future<void> deleteConversation(String conversationId) async {
|
|
try {
|
|
await _repository.deleteConversation(conversationId);
|
|
// Remove from local state
|
|
final updatedMessages =
|
|
Map<String, List<ChatMessage>>.from(state.messages)
|
|
..remove(conversationId);
|
|
final updatedConversations =
|
|
state.conversations.where((c) => c.id != conversationId).toList();
|
|
final totalUnread = updatedConversations.fold<int>(
|
|
0, (sum, c) => sum + c.unreadCount,
|
|
);
|
|
state = state.copyWith(
|
|
messages: updatedMessages,
|
|
conversations: updatedConversations,
|
|
unreadCount: totalUnread,
|
|
clearActiveConversation: true,
|
|
);
|
|
} catch (_) {
|
|
state = state.copyWith(error: 'Failed to delete conversation');
|
|
}
|
|
}
|
|
|
|
Future<void> toggleMute(String conversationId, bool muted) async {
|
|
try {
|
|
await _repository.toggleMute(conversationId, muted);
|
|
final currentUserId = _ref.read(currentUserIdProvider);
|
|
final updatedConversations = state.conversations.map((c) {
|
|
if (c.id == conversationId) {
|
|
final isUser = c.userId == currentUserId;
|
|
return isUser
|
|
? c.copyWith(userMuted: muted)
|
|
: c.copyWith(agentMuted: muted);
|
|
}
|
|
return c;
|
|
}).toList();
|
|
state = state.copyWith(conversations: updatedConversations);
|
|
} catch (_) {
|
|
state = state.copyWith(error: 'Failed to toggle mute');
|
|
}
|
|
}
|
|
|
|
Future<void> toggleFavorite(String conversationId, bool favorited) async {
|
|
try {
|
|
await _repository.toggleFavorite(conversationId, favorited);
|
|
final currentUserId = _ref.read(currentUserIdProvider);
|
|
var updatedConversations = state.conversations.map((c) {
|
|
if (c.id == conversationId) {
|
|
final isUser = c.userId == currentUserId;
|
|
return isUser
|
|
? c.copyWith(userFavorited: favorited)
|
|
: c.copyWith(agentFavorited: favorited);
|
|
}
|
|
return c;
|
|
}).toList();
|
|
// Sort: favorites first, then by lastMessageAt
|
|
updatedConversations.sort((a, b) {
|
|
final aFav = a.userId == currentUserId ? a.userFavorited : a.agentFavorited;
|
|
final bFav = b.userId == currentUserId ? b.userFavorited : b.agentFavorited;
|
|
if (aFav != bFav) {
|
|
return aFav ? -1 : 1;
|
|
}
|
|
final aTime = a.lastMessageAt ?? '';
|
|
final bTime = b.lastMessageAt ?? '';
|
|
return bTime.compareTo(aTime);
|
|
});
|
|
state = state.copyWith(conversations: updatedConversations);
|
|
} catch (_) {
|
|
state = state.copyWith(error: 'Failed to toggle favorite');
|
|
}
|
|
}
|
|
}
|
|
|
|
final messagingProvider =
|
|
StateNotifierProvider<MessagingNotifier, MessagingState>((ref) {
|
|
return MessagingNotifier(ref.watch(messagingRepositoryProvider), ref);
|
|
});
|
|
|
|
final currentUserIdProvider = Provider<String?>((ref) {
|
|
return ref.watch(authProvider).user?.id;
|
|
});
|