2026-01-13 11:36:24 +05:30

2085 lines
67 KiB
Dart

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<void> 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<String, List<ChatMessage>> messagesMap =
<String, List<ChatMessage>>{}.obs;
// Make these properly observable......................................................
final RxMap<String, ChatMessage> lastMessages = <String, ChatMessage>{}.obs;
final RxMap<String, int> unreadCounts = <String, int>{}.obs;
RxList<ChatConversation> recentConversations = <ChatConversation>[].obs;
// Pagination related variables..........................................................
final RxMap<String, String> cursors = <String, String>{}.obs;
final RxMap<String, bool> isLoadingMore = <String, bool>{}.obs;
final RxMap<String, bool> hasMoreMessages = <String, bool>{}.obs;
// File download tracking...............................................................
final RxMap<String, double> downloadProgress = <String, double>{}.obs;
final RxMap<String, bool> isDownloading = <String, bool>{}.obs;
// File sending/upload tracking........................................................
final RxMap<String, bool> isSending = <String, bool>{}.obs;
RxString selectedUserId = "".obs;
final RxMap<String, String> presenceMap = <String, String>{}.obs;
void addPresenceListener() {
ChatClient.getInstance.presenceManager.addEventHandler(
'presence_handler',
ChatPresenceEventHandler(
onPresenceStatusChanged: (List<ChatPresence> 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<void> subscribeToUserPresence(String userId) async {
try {
List<ChatPresence> 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<ChatMessage>.from(currentMessages);
messagesMap.refresh(); // Force GetX to notify observers
}
}
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<bool> 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<void> 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<ChatConversation>.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<void> 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<List<ChatMessage>> 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<List<ChatMessage>> 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<ChatMessage> 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<String> 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<void> 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<ChatMessage> result = await ChatClient
.getInstance
.chatManager
.fetchHistoryMessagesByOption(
conversationId,
conversationType,
options: options,
cursor: cursors[conversationId] ?? '',
pageSize: 20, // Load 20 messages at a time
);
final List<ChatMessage> 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<ChatMessage> allMessages = [
...newMessages,
...existingMessages,
];
// Remove duplicates based on message ID
final Map<String, ChatMessage> uniqueMessages = {};
for (final msg in allMessages) {
uniqueMessages[msg.msgId] = msg;
}
// Convert back to list and sort by timestamp
final List<ChatMessage> 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<void> loadRecentChats() async {
try {
List<ChatConversation> 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<void> _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<void> _fetchServerMessageHistory(ChatConversationType type) async {
try {
final currentUserId = ChatClient.getInstance.currentUserId;
if (currentUserId == null) return;
final ChatCursorResult<ChatMessage> 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<ChatMessage> messages = result.data ?? [];
// Group messages by conversation
final Map<String, List<ChatMessage>> 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<String> 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<void> loadAllMessages(String conversationId) async {
await loadMessages(conversationId);
while (hasMoreMessages[conversationId] == true) {
await loadMoreMessages(conversationId);
await Future.delayed(const Duration(milliseconds: 100));
}
}
Future<void> 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<void> 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<String, ChatUserInfo> userInfoMap = <String, ChatUserInfo>{}.obs;
final RxMap<String, ChatGroup> groupInfoMap = <String, ChatGroup>{}.obs;
Future<void> 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<void> 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<void> 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<void> 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<void> 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<String, PlayerController> playerControllers =
<String, PlayerController>{}.obs;
final RxMap<String, bool> isPlayingVoice = <String, bool>{}.obs;
final RxMap<String, int> voiceCurrentPosition = <String, int>{}.obs;
final RxMap<String, int> voiceMaxDuration = <String, int>{}.obs;
final RxBool isRecordingPaused = false.obs;
Timer? recordingTimer;
final RxMap<String, bool> hasCompleted = <String, bool>{}.obs;
//............................................................................................................
//............................................................................................................
// Pause recording (for app lifecycle changes)
Future<void> 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<void> 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<void> initializeRecorder() async {
recorderController =
RecorderController()
..androidEncoder = AndroidEncoder.aac
..androidOutputFormat = AndroidOutputFormat.mpeg4
..iosEncoder = IosEncoder.kAudioFormatMPEG4AAC
..sampleRate = 44100;
}
//............................................................................................................
//............................................................................................................
// Request microphone permission
Future<bool> 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<void> 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<void> 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<void> 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<PlayerController> 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<PlayerController> 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<void> 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<void> 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<void> 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')}';
}
//............................................................................................................
//............................................................................................................
}