import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:onufitness/services/logger_service.dart'; import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:agora_chat_sdk/agora_chat_sdk.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; import 'package:onufitness/services/api_services/base_api_services.dart'; import 'package:onufitness/utils/custom_sneakbar.dart'; import 'package:path_provider/path_provider.dart'; import 'package:dio/dio.dart'; import 'package:permission_handler/permission_handler.dart'; class ChatController extends GetxController with WidgetsBindingObserver { //............................................................................................................ final logger = LoggerService(); @override void onInit() async { super.onInit(); addListeners(); addPresenceListener(); WidgetsBinding.instance.addObserver(this); } //............................................................................................................ @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.hidden) { if (isRecording.value) { pauseRecording(); } } } //............................................................................................................ @override void onClose() { // Remove event handlers first ChatClient.getInstance.chatManager.removeMessageEvent('chat_listener'); ChatClient.getInstance.chatManager.removeEventHandler("receive_chat"); ChatClient.getInstance.chatManager.removeEventHandler( "delivery_message_listioner", ); ChatClient.getInstance.chatManager.removeEventHandler( "read_message_listioner", ); ChatClient.getInstance.chatManager.removeEventHandler( "conversation_read_listioner", ); ChatClient.getInstance.removeConnectionEventHandler('connection_handler'); ChatClient.getInstance.presenceManager.removeEventHandler( "presence_handler", ); // Dispose recorder recorderController?.dispose(); durationTimer?.cancel(); // Dispose all players for (final controller in playerControllers.values) { controller.dispose(); } WidgetsBinding.instance.removeObserver(this); super.onClose(); } //............................................................................................................... // Delete a specific conversation and all its messages.......................................................... //............................................................................................................... Future deleteConversation(String conversationId) async { try { // Delete from Agora Chat SDK. await ChatClient.getInstance.chatManager.deleteConversation( conversationId, deleteMessages: true, ); try { await ChatClient.getInstance.chatManager.deleteRemoteConversation( conversationId, ); } on ChatError catch (e) { logger.error( "Error deleting remote conversation", error: e, stackTrace: StackTrace.current, ); } // Remove from local controller data messagesMap.remove(conversationId); lastMessages.remove(conversationId); unreadCounts.remove(conversationId); cursors.remove(conversationId); isLoadingMore.remove(conversationId); hasMoreMessages.remove(conversationId); userInfoMap.remove(conversationId); groupInfoMap.remove(conversationId); presenceMap.remove(conversationId); // Remove from recent conversations list recentConversations.removeWhere((conv) => conv.id == conversationId); if (selectedUserId.value == conversationId) { await unsuscribePresence(conversationId); selectedUserId.value = ""; } } on ChatError catch (e) { logger.error( "Failed to delete conversation $conversationId", error: e, stackTrace: StackTrace.current, ); } } // //............................................................................................................... // //............................................................................................................... // //........ Chat Code Logics...................................................................................... // //............................................................................................................... // //............................................................................................................... // Tracks messages for each conversation................................................ final RxMap> messagesMap = >{}.obs; // Make these properly observable...................................................... final RxMap lastMessages = {}.obs; final RxMap unreadCounts = {}.obs; RxList recentConversations = [].obs; // Pagination related variables.......................................................... final RxMap cursors = {}.obs; final RxMap isLoadingMore = {}.obs; final RxMap hasMoreMessages = {}.obs; // File download tracking............................................................... final RxMap downloadProgress = {}.obs; final RxMap isDownloading = {}.obs; // File sending/upload tracking........................................................ final RxMap isSending = {}.obs; RxString selectedUserId = "".obs; final RxMap presenceMap = {}.obs; void addPresenceListener() { ChatClient.getInstance.presenceManager.addEventHandler( 'presence_handler', ChatPresenceEventHandler( onPresenceStatusChanged: (List list) { for (final p in list) { final userId = p.publisher; final statusMap = p.statusDetails; bool isOnline = false; if (statusMap != null && statusMap.isNotEmpty) { isOnline = statusMap.values.any((v) => v == 1); } presenceMap[userId] = isOnline ? 'online' : 'offline'; } }, ), ); } Future subscribeToUserPresence(String userId) async { try { List subsss = await ChatClient.getInstance.presenceManager .subscribe(members: [userId], expiry: 300); for (final p in subsss) { final statusMap = p.statusDetails; bool isOnline = false; if (statusMap != null && statusMap.isNotEmpty) { isOnline = statusMap.values.any((v) => v == 1); } presenceMap[p.publisher] = isOnline ? 'online' : 'offline'; } } catch (e) { logger.error( "Presence subscription failed for $userId", error: e, stackTrace: StackTrace.current, ); } } unsuscribePresence(String userId) async { try { await ChatClient.getInstance.presenceManager.unsubscribe( members: [userId], ); presenceMap.remove(userId); } catch (e) { logger.error( "Presence unsubscribe failed", error: e, stackTrace: StackTrace.current, ); } } // File sending methods............................................................................................. final ImagePicker _picker = ImagePicker(); final Dio _dio = Dio(); void forceRefreshMessages(String conversationId) { final currentMessages = messagesMap[conversationId]; if (currentMessages != null) { // Force observable update by creating new list reference messagesMap[conversationId] = List.from(currentMessages); messagesMap.refresh(); // Force GetX to notify observers } } Future sendImageFromCamera( String targetId, { bool isGroup = false, }) async { try { final XFile? image = await _picker.pickImage(source: ImageSource.camera); if (image != null) { final file = File(image.path); final sizeInBytes = await file.length(); if (_isFileSizeValid(sizeInBytes)) { // Trigger UI update before sending isSending[targetId] = true; await sendFile( targetId: targetId, filePath: image.path, isGroup: isGroup, ); } else { customSnackbar( title: "File too large", message: "Please select a file smaller than 10 MB.", duration: 2, ); } } } catch (e) { isSending[targetId] = false; } } Future sendImageFromGallery( String targetId, { bool isGroup = false, }) async { try { final XFile? image = await _picker.pickImage(source: ImageSource.gallery); if (image != null) { final file = File(image.path); final sizeInBytes = await file.length(); if (_isFileSizeValid(sizeInBytes)) { isSending[targetId] = true; await sendFile( targetId: targetId, filePath: image.path, isGroup: isGroup, ); } else { customSnackbar( title: "File too large", message: "Please select a file smaller than 10 MB.", duration: 2, ); } } } catch (e) { isSending[targetId] = false; } } Future sendVideo(String targetId, {bool isGroup = false}) async { try { final XFile? video = await _picker.pickVideo(source: ImageSource.gallery); if (video != null) { final file = File(video.path); final sizeInBytes = await file.length(); if (_isFileSizeValid(sizeInBytes)) { // Trigger UI update before sending isSending[targetId] = true; await sendFile( targetId: targetId, filePath: video.path, isGroup: isGroup, ); } else { customSnackbar( title: "File too large", message: "Please select a file smaller than 10 MB.", duration: 2, ); } } } catch (e) { isSending[targetId] = false; } } Future sendDocument(String targetId, {bool isGroup = false}) async { try { FilePickerResult? result = await FilePicker.platform.pickFiles(); if (result != null && result.files.single.path != null) { final file = File(result.files.single.path!); final sizeInBytes = await file.length(); if (_isFileSizeValid(sizeInBytes)) { // Trigger UI update before sending isSending[targetId] = true; await sendFile( targetId: targetId, filePath: file.path, isGroup: isGroup, ); } else { customSnackbar( title: "File too large", message: "Please select a file smaller than 10 MB.", duration: 2, ); } } } catch (e) { isSending[targetId] = false; } } // Agora File Send Method............................................................................................ Future sendFile({ required String targetId, required String filePath, required bool isGroup, }) async { try { final file = File(filePath); final int fileSize = await file.length(); final String fileName = file.path.split('/').last; // Set sending state BEFORE creating message isSending[targetId] = true; final ChatMessage msg = ChatMessage.createFileSendMessage( targetId: targetId, filePath: filePath, displayName: fileName, fileSize: fileSize, ); msg.chatType = isGroup ? ChatType.GroupChat : ChatType.Chat; // Send the message await ChatClient.getInstance.chatManager.sendMessage(msg); await Future.delayed(const Duration(milliseconds: 500)); } on ChatError catch (e) { logger.error( "Send file message failed", error: e, stackTrace: StackTrace.current, ); customSnackbar( title: "Send Failed", message: "Failed to send file...", duration: 3, ); } catch (e) { logger.error( "Unexpected error sending file", error: e, stackTrace: StackTrace.current, ); customSnackbar( title: "Send Failed", message: "Failed to send file...", duration: 3, ); } } // Helper method to validate file size................................................................................ bool _isFileSizeValid(int sizeInBytes) { const maxSizeInBytes = 10 * 1024 * 1024; // 10 MB return sizeInBytes <= maxSizeInBytes; } //........................................................................................................... // File download functionality Future downloadFile(ChatMessage message) async { if (isDownloading[message.msgId] == true) return; try { isDownloading[message.msgId] = true; downloadProgress[message.msgId] = 0.0; String? remoteUrl; String fileName = ''; // Get remote URL and filename based on message type switch (message.body.type) { case MessageType.IMAGE: final imageBody = message.body as ChatImageMessageBody; remoteUrl = imageBody.remotePath; fileName = imageBody.displayName ?? 'image_${message.msgId}.jpg'; break; case MessageType.VIDEO: final videoBody = message.body as ChatVideoMessageBody; remoteUrl = videoBody.remotePath; fileName = videoBody.displayName ?? 'video_${message.msgId}.mp4'; break; case MessageType.FILE: final fileBody = message.body as ChatFileMessageBody; remoteUrl = fileBody.remotePath; fileName = fileBody.displayName ?? 'file_${message.msgId}'; break; default: return; } if (remoteUrl == null || remoteUrl.isEmpty) { return; } // Get downloads directory final Directory? downloadsDir = await getExternalStorageDirectory(); if (downloadsDir == null) { logger.error( "Could not access downloads directory", error: Exception("Downloads directory is null"), stackTrace: StackTrace.current, ); return; } final String savePath = '${downloadsDir.path}/ChatDownloads/$fileName'; final File saveFile = File(savePath); // Create directory if it doesn't exist await saveFile.parent.create(recursive: true); // Download file with progress tracking await _dio.download( remoteUrl, savePath, onReceiveProgress: (received, total) { if (total > 0) { final progress = received / total; downloadProgress[message.msgId] = progress; } }, ); downloadProgress[message.msgId] = 1.0; // Show success message or open file customSnackbar( title: "Download Complete", message: "File saved to: $savePath", ); } catch (e) { logger.error( "Download failed for message ${message.msgId}", error: e, stackTrace: StackTrace.current, ); customSnackbar( title: "Download Failed", message: "Could not download file: $e", ); } finally { isDownloading[message.msgId] = false; } } // Check if file is already downloaded Future isFileDownloaded(ChatMessage message) async { try { final Directory? downloadsDir = await getExternalStorageDirectory(); if (downloadsDir == null) return false; String fileName = ''; switch (message.body.type) { case MessageType.IMAGE: final imageBody = message.body as ChatImageMessageBody; fileName = imageBody.displayName ?? 'image_${message.msgId}.jpg'; break; case MessageType.VIDEO: final videoBody = message.body as ChatVideoMessageBody; fileName = videoBody.displayName ?? 'video_${message.msgId}.mp4'; break; case MessageType.FILE: final fileBody = message.body as ChatFileMessageBody; fileName = fileBody.displayName ?? 'file_${message.msgId}'; break; default: return false; } final String filePath = '${downloadsDir.path}/ChatDownloads/$fileName'; return File(filePath).existsSync(); } catch (e) { logger.error( "Error checking if file is downloaded", error: e, stackTrace: StackTrace.current, ); return false; } } // Open downloaded file Future openFile(ChatMessage message) async { try { final Directory? downloadsDir = await getExternalStorageDirectory(); if (downloadsDir == null) return; String fileName = ''; switch (message.body.type) { case MessageType.IMAGE: final imageBody = message.body as ChatImageMessageBody; fileName = imageBody.displayName ?? 'image_${message.msgId}.jpg'; break; case MessageType.VIDEO: final videoBody = message.body as ChatVideoMessageBody; fileName = videoBody.displayName ?? 'video_${message.msgId}.mp4'; break; case MessageType.FILE: final fileBody = message.body as ChatFileMessageBody; fileName = fileBody.displayName ?? 'file_${message.msgId}'; break; default: return; } final String filePath = '${downloadsDir.path}/ChatDownloads/$fileName'; if (File(filePath).existsSync()) { customSnackbar(title: "File Location", message: filePath); } else { customSnackbar( title: "File Not Found", message: "Please download the file first", ); } } catch (e) { logger.error( "Error opening file", error: e, stackTrace: StackTrace.current, ); } } // Listener for Send Message, Receive message............................................................................ void addListeners() { ChatClient.getInstance.chatManager.addMessageEvent( 'chat_listener', ChatMessageEvent( onSuccess: (msgId, msg) { final currentUserId = ChatClient.getInstance.currentUserId; if (msg.from == currentUserId) { final conversationKey = msg.to!; // Update messages map.............................................................. final currentList = messagesMap[conversationKey] ?? []; final updatedList = [...currentList, msg]; messagesMap[conversationKey] = updatedList; lastMessages[conversationKey] = msg; // Update recent conversations order (but only for Chat and GroupChat)............... if (msg.chatType == ChatType.Chat || msg.chatType == ChatType.GroupChat) { updateRecentConversationsOrder(conversationKey); } // Stop loading state when File is successfully sent................................. if (isSending[conversationKey] == true) { isSending[conversationKey] = false; } } }, onProgress: (msgId, progress) {}, onError: (msgId, msg, error) { logger.error( "Message send error: $msgId", error: error, stackTrace: StackTrace.current, ); if (msg.to != null) { isSending[msg.to!] = false; isSending.refresh(); } }, ), ); ChatClient.getInstance.chatManager.addEventHandler( "receive_chat", ChatEventHandler( onMessagesReceived: (messages) async { final currentUserId = ChatClient.getInstance.currentUserId; for (final msg in messages) { final conversationKey = msg.chatType == ChatType.GroupChat ? msg.to! : (msg.from == currentUserId ? msg.to! : msg.from!); // Skip messages that are not Chat or GroupChat if (msg.chatType != ChatType.Chat && msg.chatType != ChatType.GroupChat) { continue; } // Fetch group info if needed if (msg.chatType == ChatType.GroupChat) { await fetchGroupInfo(msg.to!); } // Append new message to current list final currentList = messagesMap[conversationKey] ?? []; final updatedList = [...currentList, msg]; messagesMap[conversationKey] = updatedList; // Update last message lastMessages[conversationKey] = msg; final isIncoming = msg.from != currentUserId; if (isIncoming) { try { final convo = await ChatClient.getInstance.chatManager .getConversation( conversationKey, type: msg.chatType == ChatType.GroupChat ? ChatConversationType.GroupChat : ChatConversationType.Chat, ); if (convo != null) { final unread = await convo.unreadCount(); unreadCounts[conversationKey] = unread; } } catch (e) { logger.error( "Failed to get unread count for $conversationKey", error: e, stackTrace: StackTrace.current, ); } } // Reorder recent conversations updateRecentConversationsOrder(conversationKey); // Auto-send read receipt if this user is selected in chat if (msg.from == selectedUserId.value) { ChatClient.getInstance.chatManager.sendMessageReadAck(msg); } } }, ), ); ChatClient.getInstance.chatManager.addEventHandler( "delivery_message_listener", ChatEventHandler( onMessagesDelivered: (messages) { for (final msg in messages) { final conversationKey = msg.to ?? msg.from!; updateMessageStatus(msg.msgId, conversationKey, delivered: true); } }, ), ); ChatClient.getInstance.chatManager.addEventHandler( "read_message_listener", ChatEventHandler( onMessagesRead: (messages) { for (final msg in messages) { final conversationKey = msg.to ?? msg.from!; updateMessageStatus(msg.msgId, conversationKey, read: true); } }, ), ); ChatClient.getInstance.chatManager.addEventHandler( "conversation_read_listener", ChatEventHandler( onConversationRead: (from, to) { // Update your message list for this conversation final messages = messagesMap[to]; if (messages != null) { for (final msg in messages) { msg.hasReadAck = true; } messagesMap[to] = List.from(messages); } }, ), ); ChatClient.getInstance.addConnectionEventHandler( 'connection_handler', ConnectionEventHandler( onConnected: () async { try { await ChatClient.getInstance.presenceManager.publishPresence( "online", ); } catch (e) { logger.error( "Failed to publish presence", error: e, stackTrace: StackTrace.current, ); } }, onDisconnected: () {}, onTokenWillExpire: () async {}, onTokenDidExpire: () {}, ), ); } void updateMessageStatus( String msgId, String conversationId, { bool delivered = false, bool read = false, }) { final currentList = messagesMap[conversationId]; if (currentList == null) return; final updatedList = currentList.map((msg) { if (read) msg.hasReadAck = true; if (delivered) msg.hasDeliverAck = true; return msg; }).toList(); messagesMap[conversationId] = updatedList; } // Helper method to update conversation order in recent conversations .................................................... void updateRecentConversationsOrder(String conversationKey) { final currentConversations = List.from( recentConversations, ); // Find the conversation and move it to top final existingIndex = currentConversations.indexWhere( (conv) => conv.id == conversationKey, ); if (existingIndex != -1) { // Move existing conversation to top final conversation = currentConversations.removeAt(existingIndex); currentConversations.insert(0, conversation); recentConversations.value = currentConversations; } else { // If conversation doesn't exist in recent list, reload recent chats loadRecentChats(); } } Future sendTextMessage( String targetId, String content, { bool isGroup = false, }) async { final msg = ChatMessage.createTxtSendMessage( targetId: targetId, content: content, )..chatType = isGroup ? ChatType.GroupChat : ChatType.Chat; try { await ChatClient.getInstance.chatManager.sendMessage(msg); } on ChatError catch (e) { logger.error( "Send message failed", error: e, stackTrace: StackTrace.current, ); } } RxBool isGroupChattt = false.obs; // Load initial messages for a conversation (most recent messages) // Future> loadMessages(String conversationId) async { // print("Loading initial messages for conversation: $conversationId"); // // Determine conversation type based on isGroupChattt flag // final conversationType = // isGroupChattt.value // ? ChatConversationType.GroupChat // : ChatConversationType.Chat; // print( // "Loading as ${conversationType == ChatConversationType.GroupChat ? 'GROUP' : 'SINGLE'} chat", // ); // final conv = await ChatClient.getInstance.chatManager.getConversation( // conversationId, // type: // isGroupChattt.value // ? ChatConversationType.GroupChat // : ChatConversationType.Chat, // ); // if (conv == null) { // log("No conversation found for $conversationId"); // return []; // } // try { // // Load recent messages from local database first // final messages = await conv.loadMessages(startMsgId: '', loadCount: 20); // // Sort messages by timestamp to ensure proper order // messages.sort((a, b) => a.serverTime.compareTo(b.serverTime)); // messagesMap[conversationId] = messages; // // Update last message if messages exist // if (messages.isNotEmpty) { // lastMessages[conversationId] = messages.last; // } // // Initialize pagination state // cursors[conversationId] = ''; // isLoadingMore[conversationId] = false; // hasMoreMessages[conversationId] = true; // // Load more messages from server if local messages are less than expected // if (messages.length < 20) { // await loadMoreMessages(conversationId); // } // return messages; // } on ChatError catch (e) { // log("Load messages failed: ${e.description}"); // return []; // } // } //........Above one is Original Methos ( Till 12th December 2025)......................................................................... Future> loadMessages(String conversationId) async { // Determine conversation type based on isGroupChattt flag final conversationType = isGroupChattt.value ? ChatConversationType.GroupChat : ChatConversationType.Chat; final conv = await ChatClient.getInstance.chatManager.getConversation( conversationId, type: conversationType, ); try { List messages = []; // Try loading from local first if (conv != null) { messages = await conv.loadMessages(startMsgId: '', loadCount: 50); } // Sort messages by timestamp to ensure proper order messages.sort((a, b) => a.serverTime.compareTo(b.serverTime)); // For group messages, fetch user info for all senders if (isGroupChattt.value && messages.isNotEmpty) { final Set senderIds = {}; for (final msg in messages) { if (msg.from != null && msg.from != ChatClient.getInstance.currentUserId) { senderIds.add(msg.from!); } } // Fetch user info in parallel for better performance for (final senderId in senderIds) { fetchUserInfo(senderId); } } messagesMap[conversationId] = messages; // Update last message if messages exist if (messages.isNotEmpty) { lastMessages[conversationId] = messages.last; } // Initialize pagination state cursors[conversationId] = ''; isLoadingMore[conversationId] = false; hasMoreMessages[conversationId] = true; // Load more messages from server if local messages are less than expected or if it's a group chat if (messages.length < 20 || isGroupChattt.value) { await loadMoreMessages(conversationId); } return messagesMap[conversationId] ?? []; } on ChatError catch (e) { logger.error( "Load messages failed", error: e, stackTrace: StackTrace.current, ); return []; } } /// Load more historical messages with pagination Future loadMoreMessages(String conversationId) async { if (isLoadingMore[conversationId] == true || hasMoreMessages[conversationId] == false) { return; } isLoadingMore[conversationId] = true; try { final ChatConversationType conversationType = isGroupChattt.value ? ChatConversationType.GroupChat : ChatConversationType.Chat; // Configure fetch options final FetchMessageOptions options = FetchMessageOptions( from: conversationId, direction: ChatSearchDirection.Up, needSave: true, // Save to local database ); final ChatCursorResult result = await ChatClient .getInstance .chatManager .fetchHistoryMessagesByOption( conversationId, conversationType, options: options, cursor: cursors[conversationId] ?? '', pageSize: 20, // Load 20 messages at a time ); final List newMessages = result.data ?? []; final String? nextCursor = result.cursor; if (newMessages.isNotEmpty) { // Get existing messages final existingMessages = messagesMap[conversationId] ?? []; // Sort new messages by timestamp newMessages.sort((a, b) => a.serverTime.compareTo(b.serverTime)); // Merge with existing messages (new messages go to the beginning) final List allMessages = [ ...newMessages, ...existingMessages, ]; // Remove duplicates based on message ID final Map uniqueMessages = {}; for (final msg in allMessages) { uniqueMessages[msg.msgId] = msg; } // Convert back to list and sort by timestamp final List finalMessages = uniqueMessages.values.toList() ..sort((a, b) => a.serverTime.compareTo(b.serverTime)); // Update messages map messagesMap[conversationId] = finalMessages; // Update cursor for next pagination cursors[conversationId] = nextCursor ?? ''; // Check if there are more messages to load hasMoreMessages[conversationId] = newMessages.length == 20; } else { // No more messages available hasMoreMessages[conversationId] = false; } } on ChatError catch (e) { logger.error( "Load more messages failed", error: e, stackTrace: StackTrace.current, ); hasMoreMessages[conversationId] = false; } finally { isLoadingMore[conversationId] = false; await markAllMessagesAsRead(conversationId); unreadCounts[conversationId] = 0; } } Future loadRecentChats() async { try { List localConversations = await ChatClient.getInstance.chatManager.loadAllConversations(); // Filter to include only Chat and GroupChat conversations localConversations = localConversations.where((conversation) { return conversation.type == ChatConversationType.Chat || conversation.type == ChatConversationType.GroupChat; }).toList(); // If no local conversations (new device), fetch from server...................... if (localConversations.isEmpty) { await _fetchConversationsFromServer(); // Load again after server fetch localConversations = localConversations.where((conversation) { return conversation.type == ChatConversationType.Chat || conversation.type == ChatConversationType.GroupChat; }).toList(); } // ................................................................................ // Sort conversations by last message timestamp.................................... localConversations.sort((a, b) { final aLastMessage = lastMessages[a.id]; final bLastMessage = lastMessages[b.id]; if (aLastMessage == null && bLastMessage == null) return 0; if (aLastMessage == null) return 1; if (bLastMessage == null) return -1; return bLastMessage.serverTime.compareTo(aLastMessage.serverTime); }); for (final conversation in localConversations) { final convType = conversation.type; //For showing Last Message final messages = await conversation.loadMessages( startMsgId: '', loadCount: 1, ); if (messages.isNotEmpty) { lastMessages[conversation.id] = messages.last; messagesMap[conversation.id] = messages; } final unread = await conversation.unreadCount(); unreadCounts[conversation.id] = unread; if (convType == ChatConversationType.GroupChat) { await fetchGroupInfo(conversation.id); } else if (convType == ChatConversationType.Chat) { await fetchUserInfo(conversation.id); } } recentConversations.value = localConversations; } on ChatError catch (e) { logger.error( "Failed to load recent chats", error: e, stackTrace: StackTrace.current, ); } } //............................................................................................ // Below Method is for fetching Server site conversation...................................... //............................................................................................ Future _fetchConversationsFromServer() async { try { // Get current user ID final currentUserId = ChatClient.getInstance.currentUserId; if (currentUserId == null) { logger.error( "Failed to fetch conversations: Current user ID is null. User may not be logged in or session expired.", error: Exception("Current user ID is null"), stackTrace: StackTrace.current, ); return; } // Fetch both Chat and GroupChat conversations await _fetchServerMessageHistory(ChatConversationType.Chat); await _fetchServerMessageHistory(ChatConversationType.GroupChat); } on ChatError catch (e) { // Silently ignore error 201 - session not ready yet if (e.code == 201) { logger.warning( "Session not ready for server fetch (error 201), using local data only", ); return; } logger.error( "Failed to fetch conversations from server", error: e, stackTrace: StackTrace.current, ); } } // Fetch message history from server to populate local conversations Future _fetchServerMessageHistory(ChatConversationType type) async { try { final currentUserId = ChatClient.getInstance.currentUserId; if (currentUserId == null) return; final ChatCursorResult result = await ChatClient .getInstance .chatManager .fetchHistoryMessagesByOption( currentUserId, type, options: FetchMessageOptions( direction: ChatSearchDirection.Up, needSave: true, // Important: Save to local database ), cursor: '', pageSize: 50, ); final List messages = result.data ?? []; // Group messages by conversation final Map> conversationMessages = {}; for (final message in messages) { // Skip messages that are not Chat or GroupChat if (type == ChatConversationType.Chat && message.chatType != ChatType.Chat) { continue; } if (type == ChatConversationType.GroupChat && message.chatType != ChatType.GroupChat) { continue; } String conversationId; if (type == ChatConversationType.GroupChat) { // For group chats, use the 'to' field which contains the group ID conversationId = message.to!; } else { // For single chats, use the other user's ID conversationId = message.from == currentUserId ? message.to! : message.from!; } if (!conversationMessages.containsKey(conversationId)) { conversationMessages[conversationId] = []; } conversationMessages[conversationId]!.add(message); } // Process each conversation for (final entry in conversationMessages.entries) { final conversationId = entry.key; final messages = entry.value; // Sort messages by timestamp messages.sort((a, b) => a.serverTime.compareTo(b.serverTime)); // Update local data messagesMap[conversationId] = messages; lastMessages[conversationId] = messages.last; // Get conversation to update unread count final conversation = await ChatClient.getInstance.chatManager .getConversation(conversationId, type: ChatConversationType.Chat); if (conversation != null) { final unread = await conversation.unreadCount(); unreadCounts[conversationId] = unread; } // Fetch additional info based on type if (type == ChatConversationType.GroupChat) { await fetchGroupInfo(conversationId); // Fetch user info for group message senders final Set senderIds = {}; for (final msg in messages) { if (msg.from != null && msg.from != currentUserId) { senderIds.add(msg.from!); } } for (final senderId in senderIds) { fetchUserInfo(senderId); } } else { await fetchUserInfo(conversationId); } } } on ChatError catch (e) { logger.error( "Failed to fetch server message history", error: e, stackTrace: StackTrace.current, ); } } Future loadAllMessages(String conversationId) async { await loadMessages(conversationId); while (hasMoreMessages[conversationId] == true) { await loadMoreMessages(conversationId); await Future.delayed(const Duration(milliseconds: 100)); } } Future markAllMessagesAsRead(String conversationId) async { try { final conv = await ChatClient.getInstance.chatManager.getConversation( conversationId, type: isGroupChattt.value ? ChatConversationType.GroupChat : ChatConversationType.Chat, ); if (conv != null) { await conv.markAllMessagesAsRead(); unreadCounts[conversationId] = 0; final messages = messagesMap[conversationId] ?? []; if (messages.isNotEmpty) { final lastMessage = messages.last; try { await ChatClient.getInstance.chatManager.sendConversationReadAck( conversationId, ); await ChatClient.getInstance.chatManager.sendMessageReadAck( lastMessage, ); } catch (e) { logger.error( "Failed to send read receipt", error: e, stackTrace: StackTrace.current, ); } } final index = recentConversations.indexWhere( (c) => c.id == conversationId, ); if (index != -1) { final updatedConv = await ChatClient.getInstance.chatManager .getConversation(conversationId, type: ChatConversationType.Chat); if (updatedConv != null) { recentConversations[index] = updatedConv; } } } } on ChatError catch (e) { logger.error( "Failed to mark messages as read", error: e, stackTrace: StackTrace.current, ); } } Future logout() async { try { await ChatClient.getInstance.logout(true); messagesMap.clear(); lastMessages.clear(); unreadCounts.clear(); recentConversations.clear(); cursors.clear(); isLoadingMore.clear(); hasMoreMessages.clear(); downloadProgress.clear(); isDownloading.clear(); } catch (e) { logger.error( "Agora logout failed", error: e, stackTrace: StackTrace.current, ); } } final RxMap userInfoMap = {}.obs; final RxMap groupInfoMap = {}.obs; Future fetchUserInfo(String userId) async { if (userInfoMap.containsKey(userId)) return; try { final result = await ChatClient.getInstance.userInfoManager .fetchUserInfoById([userId]); if (result.isNotEmpty) { userInfoMap[userId] = result[userId]!; } } catch (e) { logger.error( "Failed to fetch user info for $userId", error: e, stackTrace: StackTrace.current, ); } } Future fetchGroupInfo(String groupId) async { if (groupInfoMap.containsKey(groupId)) return; try { // First try to get group from specification (more detailed info) final groupSpec = await ChatClient.getInstance.groupManager .fetchGroupInfoFromServer(groupId); groupInfoMap[groupId] = groupSpec; return; } catch (e) { // Silently fallback to local cache } // Fallback: Get group from local cache final group = await ChatClient.getInstance.groupManager.getGroupWithId( groupId, ); if (group != null) { groupInfoMap[groupId] = group; return; } // Final fallback: Try to get from joined groups list try { final groupList = await ChatClient.getInstance.groupManager.getJoinedGroups(); final group = groupList.firstWhereOrNull((g) => g.groupId == groupId); if (group != null) { groupInfoMap[groupId] = group; } } catch (e2, stackTrace) { logger.error( "Failed to get group info from joined groups list as fallback", error: e2, stackTrace: stackTrace, ); } } //........................................................................................ String getFileExtension(String fileName) { final parts = fileName.split('.'); return (parts.length > 1) ? '.${parts.last}' : ''; } bool isImageExtension(String ext) { const imageExts = ['.jpg', '.jpeg', '.png', '.webp', '.heic']; return imageExts.contains(ext); } bool isVideoExtension(String ext) { const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.ogg']; return videoExts.contains(ext); } bool isDocumentExtension(String ext) { const docExts = [ // Text & documents '.txt', '.rtf', '.pdf', '.doc', '.docx', '.odt', '.pages', // Spreadsheets '.xls', '.xlsx', '.csv', '.ods', '.numbers', // Presentations '.ppt', '.pptx', '.odp', '.key', // E-books '.epub', '.mobi', // Images in documents '.webp', '.gif', '.tiff', '.bmp', '.ico', '.svg', ]; return docExts.contains(ext.toLowerCase()); } //..Send Notification Methods.................................................................................... var isSendingChatNotification = false.obs; var isSendingGroupChatNotification = false.obs; Future sendChatNotification({ required String notificationTitle, required int notificationType, required String receiverId, required String notificationMessage, }) async { try { isSendingChatNotification.value = true; var body = { "notificationTitle": notificationTitle, "notificationType": notificationType, "receiverId": receiverId, "notificationMessage": notificationMessage, }; final response = await ApiBase.postRequest( extendedURL: '/api/Notifications/sent-chat-notification', body: body, ); if (response.statusCode != 200) { logger.error( 'sendChatNotification failed with status ${response.statusCode}', extras: { 'extras statusCode': response.statusCode.toString(), 'extras responseBody': response.body, }, error: {'error body': response.body.toString()}, ); return; } } catch (e) { logger.error( '"catch chat notification :" Failed to send group chat notification', error: e, stackTrace: StackTrace.current, ); } finally { isSendingChatNotification.value = false; } } /// Send group chat notification to a tribe Future sendGroupChatNotification({ required String notificationTitle, required int notificationType, required String tribeId, required String notificationMessage, }) async { isSendingGroupChatNotification.value = true; try { var body = { "notificationTitle": notificationTitle, "notificationType": notificationType, "tribeId": tribeId, "notificationMessage": notificationMessage, }; final response = await ApiBase.postRequest( extendedURL: '/api/Notifications/sent-group-chat-notification', body: body, ); if (response.statusCode != 200) { logger.error( 'sendGroupChatNotification failed with status ${response.statusCode}', extras: { 'extras statusCode': response.statusCode.toString(), 'extras responseBody': response.body, }, error: {'error body': response.body.toString()}, ); return; } } catch (e) { logger.error( '"catch chat notification :" Failed to send group chat notification', error: e, stackTrace: StackTrace.current, ); } finally { isSendingGroupChatNotification.value = false; } } //....You are no longer friend api call................................................ RxBool isMyFriend = false.obs; RxBool isMyFriendCheckLoading = false.obs; Future friendCheck({required String connectedAgoraUserID}) async { isMyFriendCheckLoading(true); try { final response = await ApiBase.getRequest( extendedURL: '/api/Socials/get-user-connection-exist-agora/$connectedAgoraUserID', ); if (response.statusCode == 200 || response.statusCode == 201) { final decoded = jsonDecode(response.body); isMyFriend.value = decoded['data'] == true; } else { isMyFriend.value = false; } } catch (e) { logger.error( "Error in friendCheck", error: e, stackTrace: StackTrace.current, ); isMyFriend.value = false; } finally { isMyFriendCheckLoading(false); } } //...................................................................................... //............................................................................................................ //............................................................................................................ //..Voice recording related Variables and Methods............................................................. //............................................................................................................ //............................................................................................................ RecorderController? recorderController; final RxBool isRecording = false.obs; final RxString recordingPath = ''.obs; final RxInt recordingDuration = 0.obs; Timer? durationTimer; // Voice playback tracking final RxMap playerControllers = {}.obs; final RxMap isPlayingVoice = {}.obs; final RxMap voiceCurrentPosition = {}.obs; final RxMap voiceMaxDuration = {}.obs; final RxBool isRecordingPaused = false.obs; Timer? recordingTimer; final RxMap hasCompleted = {}.obs; //............................................................................................................ //............................................................................................................ // Pause recording (for app lifecycle changes) Future pauseRecording() async { try { if (recorderController != null && isRecording.value) { await recorderController!.pause(); durationTimer?.cancel(); isRecordingPaused.value = true; } } catch (e) { logger.error( "Failed to pause recording", error: e, stackTrace: StackTrace.current, ); // If pause fails, cancel the recording to be safe cancelRecording(); } } //............................................................................................................ //............................................................................................................ // method to toggle pause/resume void togglePauseRecording() async { if (recorderController == null) return; if (isRecordingPaused.value) { await recorderController!.record(); isRecordingPaused.value = false; _startDurationTimer(); } else { // Pause recording await recorderController!.pause(); isRecordingPaused.value = true; durationTimer?.cancel(); } } //............................................................................................................ //............................................................................................................ // Cancel recording Future cancelRecording() async { try { if (recorderController != null && isRecording.value) { await recorderController!.stop(); durationTimer?.cancel(); // Delete the recorded file if (recordingPath.value.isNotEmpty) { final file = File(recordingPath.value); if (file.existsSync()) { file.deleteSync(); } } isRecording.value = false; isRecordingPaused.value = false; recordingPath.value = ''; recordingDuration.value = 0; } } catch (e) { logger.error( "Failed to cancel recording", error: e, stackTrace: StackTrace.current, ); } } //............................................................................................................ //............................................................................................................ // Initialize recorder controller Future initializeRecorder() async { recorderController = RecorderController() ..androidEncoder = AndroidEncoder.aac ..androidOutputFormat = AndroidOutputFormat.mpeg4 ..iosEncoder = IosEncoder.kAudioFormatMPEG4AAC ..sampleRate = 44100; } //............................................................................................................ //............................................................................................................ // Request microphone permission Future requestMicrophonePermission() async { final status = await Permission.microphone.request(); if (status.isGranted) { return true; } else if (status.isPermanentlyDenied) { await openAppSettings(); return false; } return false; } //............................................................................................................ //............................................................................................................ // Start voice recording Future startRecording(String conversationId) async { try { final hasPermission = await requestMicrophonePermission(); if (!hasPermission) { customSnackbar( title: "Permission Required", message: "Microphone permission is required to record voice messages", duration: 3, ); return; } if (recorderController == null) { await initializeRecorder(); } // Get temporary directory for recording final directory = await getTemporaryDirectory(); final timestamp = DateTime.now().millisecondsSinceEpoch; final path = '${directory.path}/voice_$timestamp.m4a'; // Start recording await recorderController!.record(path: path); isRecording.value = true; recordingPath.value = path; recordingDuration.value = 0; // Update duration every second _startDurationTimer(); } catch (e) { logger.error( "Failed to start recording", error: e, stackTrace: StackTrace.current, ); customSnackbar( title: "Recording Failed", message: "Could not start recording. Please try again.", duration: 3, ); } } //............................................................................................................ //............................................................................................................ void _startDurationTimer() { durationTimer?.cancel(); // Cancel any existing timer first durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (isRecording.value && !isRecordingPaused.value) { recordingDuration.value++; } }); } //............................................................................................................ //............................................................................................................ // Stop recording and send Future stopAndSendRecording( String targetId, { bool isGroup = false, }) async { try { if (recorderController == null || !isRecording.value) return; // Stop recording final path = await recorderController!.stop(); durationTimer?.cancel(); isRecording.value = false; if (path == null || path.isEmpty) { return; } // Check if recording is too short (less than 1 second) if (recordingDuration.value < 1) { customSnackbar( title: "Recording Too Short", message: "Please record for at least 1 second", duration: 2, ); File(path).deleteSync(); return; } // Send voice message method call await sendVoiceMessage( targetId: targetId, voicePath: path, duration: recordingDuration.value, isGroup: isGroup, ); // Reset recording state recordingPath.value = ''; recordingDuration.value = 0; } catch (e) { logger.error( "Failed to stop recording", error: e, stackTrace: StackTrace.current, ); isRecording.value = false; customSnackbar( title: "Send Failed", message: "Could not send voice message", duration: 3, ); } } //............................................................................................................ //............................................................................................................ // Send voice message Future sendVoiceMessage({ required String targetId, required String voicePath, required int duration, required bool isGroup, }) async { try { final file = File(voicePath); if (!file.existsSync()) { return; } // Set sending state isSending[targetId] = true; // Create voice message final msg = ChatMessage.createVoiceSendMessage( targetId: targetId, filePath: voicePath, duration: duration, displayName: 'voice_${DateTime.now().millisecondsSinceEpoch}.m4a', ); msg.chatType = isGroup ? ChatType.GroupChat : ChatType.Chat; // Send the message await ChatClient.getInstance.chatManager.sendMessage(msg); } on ChatError catch (e) { logger.error( "Send voice message failed", error: e, stackTrace: StackTrace.current, ); customSnackbar( title: "Send Failed", message: "Failed to send voice message", duration: 2, ); } catch (e) { logger.error( "Unexpected error sending voice", error: e, stackTrace: StackTrace.current, ); customSnackbar( title: "Send Failed", message: "Failed to send voice message", duration: 2, ); } finally { isSending[targetId] = false; } } //............................................................................................................ //............................................................................................................ Future getPlayerController( String msgId, String audioPath, ) async { if (playerControllers.containsKey(msgId)) { return playerControllers[msgId]!; } final controller = PlayerController(); try { await controller.preparePlayer( path: audioPath, shouldExtractWaveform: true, ); // Set up player state listener controller.onPlayerStateChanged.listen((state) { if (state == PlayerState.stopped) { isPlayingVoice[msgId] = false; hasCompleted[msgId] = true; voiceCurrentPosition[msgId] = 0; } else if (state == PlayerState.paused) { isPlayingVoice[msgId] = false; } else if (state == PlayerState.playing) { isPlayingVoice[msgId] = true; hasCompleted[msgId] = false; } }); // Set up position listener controller.onCurrentDurationChanged.listen((duration) { voiceCurrentPosition[msgId] = duration; // Check if playback completed final maxDur = voiceMaxDuration[msgId] ?? 0; if (maxDur > 0 && duration >= maxDur - 300) { hasCompleted[msgId] = true; } }); // Get max duration final maxDuration = await controller.getDuration(DurationType.max); voiceMaxDuration[msgId] = maxDuration; playerControllers[msgId] = controller; isPlayingVoice[msgId] = false; voiceCurrentPosition[msgId] = 0; hasCompleted[msgId] = false; return controller; } catch (e) { logger.error( "Failed to prepare player for $msgId", error: e, stackTrace: StackTrace.current, ); rethrow; } } //............................................................................................................ //............................................................................................................ Future refreshPlayer(String msgId, String audioPath) async { // Dispose old player if exists if (playerControllers.containsKey(msgId)) { try { await playerControllers[msgId]!.stopPlayer(); playerControllers[msgId]!.dispose(); playerControllers.remove(msgId); } catch (e) { logger.error( "Error disposing old player", error: e, stackTrace: StackTrace.current, ); } } // Clear state isPlayingVoice.remove(msgId); voiceCurrentPosition.remove(msgId); voiceMaxDuration.remove(msgId); hasCompleted.remove(msgId); // Create new player return await getPlayerController(msgId, audioPath); } //............................................................................................................ //............................................................................................................ //togglePlayVoice METHOD Future togglePlayVoice(ChatMessage message) async { try { final msgId = message.msgId; final voiceBody = message.body as ChatVoiceMessageBody; String? audioPath; // Check local path first if (voiceBody.localPath.isNotEmpty && File(voiceBody.localPath).existsSync()) { audioPath = voiceBody.localPath; } else if (voiceBody.remotePath?.isNotEmpty == true) { // Download if needed final directory = await getTemporaryDirectory(); final localPath = '${directory.path}/voice_${message.msgId}.m4a'; if (!File(localPath).existsSync()) { await _dio.download(voiceBody.remotePath!, localPath); } audioPath = localPath; } if (audioPath == null) { customSnackbar( title: "Playback Failed", message: "Voice message not available", duration: 2, ); return; } // Get current player state final isCurrentlyPlaying = isPlayingVoice[msgId] ?? false; final hasPlayerCompleted = hasCompleted[msgId] ?? false; // Get player controller - refresh if completed PlayerController controller; if (hasPlayerCompleted && !isCurrentlyPlaying) { controller = await refreshPlayer(msgId, audioPath); } else { controller = await getPlayerController(msgId, audioPath); } // Toggle play/pause if (isCurrentlyPlaying) { await controller.pausePlayer(); } else { // Stop all other players first for (final entry in playerControllers.entries) { if (entry.key != msgId && isPlayingVoice[entry.key] == true) { try { await entry.value.pausePlayer(); } catch (e) { logger.error( "Error pausing other player", error: e, stackTrace: StackTrace.current, ); } } } await controller.startPlayer(); } } catch (e) { logger.error( "Failed to toggle voice playback", error: e, stackTrace: StackTrace.current, ); customSnackbar( title: "Playback Error", message: "Could not play this voice message", duration: 2, ); } } //............................................................................................................ //............................................................................................................ Future seekVoiceMessage(ChatMessage message, double percentage) async { try { final msgId = message.msgId; final voiceBody = message.body as ChatVoiceMessageBody; String? audioPath; // Check local path first if (voiceBody.localPath.isNotEmpty && File(voiceBody.localPath).existsSync()) { audioPath = voiceBody.localPath; } else if (voiceBody.remotePath?.isNotEmpty == true) { // Download if needed final directory = await getTemporaryDirectory(); final localPath = '${directory.path}/voice_${message.msgId}.m4a'; if (!File(localPath).existsSync()) { await _dio.download(voiceBody.remotePath!, localPath); } audioPath = localPath; } if (audioPath == null) { return; } // Get or create player controller PlayerController controller; if (playerControllers.containsKey(msgId)) { controller = playerControllers[msgId]!; } else { controller = await getPlayerController(msgId, audioPath); } // Calculate target position in milliseconds final maxDuration = voiceMaxDuration[msgId] ?? (voiceBody.duration) * 1000; final targetPosition = (maxDuration * percentage).toInt(); // Seek to position await controller.seekTo(targetPosition); // Update current position voiceCurrentPosition[msgId] = targetPosition; // Start playing from this position if not already playing if (isPlayingVoice[msgId] != true) { // Stop all other players first for (final entry in playerControllers.entries) { if (entry.key != msgId && isPlayingVoice[entry.key] == true) { try { await entry.value.pausePlayer(); } catch (e) { // Silently ignore pause errors for other players } } } await controller.startPlayer(); } } catch (e) { logger.error( "Failed to seek voice message", error: e, stackTrace: StackTrace.current, ); customSnackbar( title: "Seek Error", message: "Could not seek to position", duration: 2, ); } } // Dispose player for a message............................................................................................. Future disposePlayer(String msgId) async { if (playerControllers.containsKey(msgId)) { playerControllers[msgId]!.dispose(); playerControllers.remove(msgId); isPlayingVoice.remove(msgId); voiceCurrentPosition.remove(msgId); voiceMaxDuration.remove(msgId); hasCompleted.remove(msgId); } } // Format duration for display String formatVoiceDuration(int seconds) { final minutes = seconds ~/ 60; final remainingSeconds = seconds % 60; return '${minutes.toString().padLeft(1, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; } //............................................................................................................ //............................................................................................................ }