2085 lines
67 KiB
Dart
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')}';
|
|
}
|
|
|
|
//............................................................................................................
|
|
//............................................................................................................
|
|
}
|