MIF_E31222656/lib/screens/community/services/group_message_service.dart

717 lines
24 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:tugas_akhir_supabase/screens/community/models/group_message.dart';
import 'package:uuid/uuid.dart';
import 'dart:math';
class MessageSendResult {
final bool success;
final String? errorMessage;
final GroupMessage? message;
MessageSendResult({required this.success, this.errorMessage, this.message});
}
class GroupMessageService {
final _supabase = Supabase.instance.client;
final _uuid = Uuid();
Timer? _refreshTimer;
Timer? _readStatusTimer;
RealtimeChannel? _messagesChannel;
final Map<String, String> _profilePictureCache = {};
String? get currentUserId => _supabase.auth.currentUser?.id;
// Load messages for a specific group
Future<MessageResult> loadMessages(
String groupId, {
bool forceRefresh = false,
bool loadNew = false,
List<GroupMessage> existingMessages = const [],
}) async {
try {
print(
'[FORCE] Loading messages for group: $groupId, forceRefresh: $forceRefresh, loadNew: $loadNew',
);
// Use a much shorter timeout
const timeout = Duration(seconds: 3);
// More aggressive approach: Try direct query with different options
List<dynamic> response = [];
Exception? lastError;
bool success = false;
// First attempt - Use RPC function that bypasses RLS
try {
response = await _supabase
.rpc('get_messages_for_group', params: {'group_id_param': groupId})
.timeout(timeout);
success = true;
print(
'[FORCE] First query attempt successful: ${response.length} messages',
);
} catch (e) {
lastError = e is Exception ? e : Exception(e.toString());
print('[FORCE] First query attempt failed: $e');
// Fall back to simple query
try {
response = await _supabase
.from('messages')
.select()
.eq('group_id', groupId)
.order('created_at', ascending: false)
.limit(20)
.timeout(timeout);
success = true;
print(
'[FORCE] Simplified query successful: ${response.length} messages',
);
} catch (e2) {
print('[FORCE] Simplified query failed: $e2');
}
}
// If no messages found for default group, create a test message
if (success &&
response.isEmpty &&
groupId == '00000000-0000-0000-0000-000000000001') {
print(
'[AUTO] No messages found for default group, creating test message',
);
try {
// Get current user
final userId = currentUserId;
if (userId != null) {
// Try to get username from profiles
String username = 'User';
String email = '';
try {
final profileData =
await _supabase
.from('profiles')
.select('username, email')
.eq('user_id', userId)
.single();
username = profileData['username'] ?? 'User';
email = profileData['email'] ?? '';
} catch (e) {
print('[ERROR] Failed to get profile: $e');
}
// Insert test welcome message
final messageId = _uuid.v4();
final messageData = {
'id': messageId,
'content':
'Selamat datang di Grup TaniSM4RT! Ini adalah pesan otomatis. Silakan memulai percakapan.',
'sender_user_id': userId,
'sender_username': username,
'sender_email': email,
'group_id': groupId,
'created_at': DateTime.now().toIso8601String(),
};
// Insert into database
final result =
await _supabase.from('messages').insert(messageData).select();
print(
'[AUTO] Created test message: ${result.isNotEmpty ? "Success" : "Failed"}',
);
// Add to response
if (result.isNotEmpty) {
response = result;
}
// Force reload after short delay
Future.delayed(Duration(milliseconds: 300), () {
loadMessages(groupId, forceRefresh: true);
});
}
} catch (e) {
print('[ERROR] Failed to create test message: $e');
}
}
print(
'[FORCE] Successfully loaded ${response.length} messages from database',
);
final messages = _parseMessages(response);
return MessageResult(messages: messages, hasMore: messages.length == 20);
} catch (e) {
print('[ERROR] Failed to load messages after multiple attempts: $e');
return MessageResult(messages: [], hasMore: false);
}
}
// Load more messages (pagination)
Future<MessageResult> loadMoreMessages(
String groupId,
List<GroupMessage> existingMessages,
) async {
try {
if (existingMessages.isEmpty) {
return await loadMessages(groupId);
}
const limit = 20;
// Use the messages_with_sender view
final response = await _supabase
.from('messages_with_sender')
.select()
.eq('group_id', groupId)
.order('created_at', ascending: false)
.limit(limit + existingMessages.length);
final allMessages = _parseMessages(response);
// Filter out messages we already have (client-side)
final existingIds = existingMessages.map((m) => m.id).toSet();
final newMessages =
allMessages.where((m) => !existingIds.contains(m.id)).toList();
return MessageResult(
messages: newMessages,
hasMore: newMessages.length == limit,
);
} catch (e) {
print('[ERROR] Failed to load more messages: $e');
return MessageResult(messages: [], hasMore: false);
}
}
// Parse messages from database response
List<GroupMessage> _parseMessages(List<dynamic> response) {
return response
.map((data) {
// Check if this is a JSON string from RPC function
final Map<String, dynamic> messageData;
if (data is String) {
// Parse JSON string from RPC function
try {
messageData = jsonDecode(data);
} catch (e) {
print('[ERROR] Failed to parse message JSON: $e');
return null;
}
} else if (data is Map) {
// Direct database response
messageData = Map<String, dynamic>.from(data);
} else {
print('[ERROR] Unknown message data format: ${data.runtimeType}');
return null;
}
// Create message from map
try {
return GroupMessage.fromMap({
'id': messageData['id'],
'content': messageData['content'] ?? '',
'sender_user_id': messageData['sender_user_id'],
'sender_username':
messageData['profile_username'] ??
messageData['sender_username'] ??
'',
'sender_email':
messageData['profile_email'] ??
messageData['sender_email'] ??
'',
'created_at': messageData['created_at'],
'image_url': messageData['image_url'],
'reply_to_id': messageData['reply_to_id'],
'reply_to_content': messageData['reply_to_content'],
'reply_to_sender_email': messageData['reply_to_sender_email'],
'avatar_url': messageData['avatar_url'],
'group_id': messageData['group_id'],
});
} catch (e) {
print('[ERROR] Failed to create message from data: $e');
return null;
}
})
.where((message) => message != null)
.cast<GroupMessage>()
.toList();
}
// Send message to group
Future<MessageSendResult> sendMessage({
required String groupId,
String? text,
File? imageFile,
GroupMessage? replyToMessage,
String? currentUsername,
String? currentEmail,
Function(GroupMessage)? onOptimisticUpdate,
}) async {
final messageText = text?.trim() ?? '';
// Allow empty text when sending an image, but require at least one (text or image)
if (messageText.isEmpty && imageFile == null) {
return MessageSendResult(
success: false,
errorMessage: 'No content to send',
);
}
try {
// Get current user ID
final userId = _supabase.auth.currentUser?.id;
if (userId == null) {
throw Exception('User not logged in');
}
final userEmail = currentEmail ?? _supabase.auth.currentUser?.email ?? '';
// Generate ID
final messageId = _uuid.v4(); // UUID valid
print('[DEBUG] Sending group message: $messageId');
print(
'[DEBUG] Group message text: "$messageText", has image: ${imageFile != null}',
);
// Prepare message data
final messageData = {
'id': messageId,
'content': messageText, // Always include content even if empty
'sender_email': userEmail,
'sender_username': currentUsername ?? userEmail.split('@')[0],
'sender_user_id': userId,
'group_id': groupId,
'created_at': DateTime.now().toIso8601String(),
};
// Add reply data if replying to a message
if (replyToMessage != null) {
messageData['reply_to_id'] = replyToMessage.id;
messageData['reply_to_content'] = replyToMessage.content;
messageData['reply_to_sender_email'] = replyToMessage.senderEmail;
messageData['reply_to_sender_username'] = replyToMessage.senderUsername;
}
// Create an optimistic message object for immediate UI update
GroupMessage optimisticMessage;
// For image uploads, create a special optimistic message with local image file
if (imageFile != null) {
optimisticMessage = GroupMessage.fromMap({
...messageData,
'created_at': DateTime.now().toIso8601String(),
'isLocalImage': true,
'localImageFile': imageFile,
'content': messageText, // Ensure content is preserved
});
} else {
optimisticMessage = GroupMessage.fromMap({
...messageData,
'created_at': DateTime.now().toIso8601String(),
});
}
// Call the optimistic update function if provided
if (onOptimisticUpdate != null) {
onOptimisticUpdate(optimisticMessage);
}
// If there's an image, upload it first
if (imageFile != null) {
try {
final imageUrl = await _uploadImage(imageFile);
if (imageUrl != null) {
messageData['image_url'] = imageUrl;
}
} catch (e) {
print('[ERROR] Failed to upload image: $e');
return MessageSendResult(
success: false,
errorMessage: 'Gagal mengunggah gambar: ${e.toString()}',
);
}
}
print(
'[DEBUG] Saving group message to database: ${messageData.toString()}',
);
bool saveSuccess = false;
try {
// First try with all data including reply fields
await _supabase.from('messages').insert(messageData);
print('[DEBUG] Group message saved successfully');
saveSuccess = true;
} catch (e) {
print('[ERROR] Failed to save group message: $e');
// If the message has reply data, try without it
if (replyToMessage != null) {
print('[DEBUG] Retrying without reply data');
// Remove reply fields
final retryData = Map<String, dynamic>.from(messageData);
retryData.remove('reply_to_id');
retryData.remove('reply_to_content');
retryData.remove('reply_to_sender_email');
retryData.remove('reply_to_sender_username');
try {
await _supabase.from('messages').insert(retryData);
print('[DEBUG] Group message saved without reply data');
saveSuccess = true;
} catch (retryError) {
print('[ERROR] Retry also failed: $retryError');
return MessageSendResult(
success: false,
errorMessage: 'Gagal menyimpan pesan: ${retryError.toString()}',
);
}
} else {
return MessageSendResult(
success: false,
errorMessage: 'Gagal menyimpan pesan: ${e.toString()}',
);
}
}
// Return success
return MessageSendResult(
success: saveSuccess,
message: optimisticMessage,
);
} catch (e) {
print('[ERROR] Failed to send group message: $e');
return MessageSendResult(success: false, errorMessage: e.toString());
}
}
// Delete a message
Future<bool> deleteMessage(GroupMessage message) async {
try {
await _supabase.from('messages').delete().eq('id', message.id);
return true;
} catch (e) {
print('[ERROR] Failed to delete message: $e');
return false;
}
}
// Real-time subscription
void setupMessagesSubscription(
String groupId,
Function(GroupMessage) onNewMessage,
Function(String, String) onReadStatusUpdate,
) async {
try {
print('[STREAM] Setting up real-time subscription for group: $groupId');
// Unsubscribe from any existing subscription
_messagesChannel?.unsubscribe();
_messagesChannel = null;
// Set up new subscription
_messagesChannel = _supabase
.channel('public:messages')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'messages', // Listen specifically to the messages table
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'group_id',
value: groupId,
),
callback: (payload) async {
try {
print(
'[STREAM] New message received: ${payload.newRecord['id']}',
);
// Skip our own messages as we already show them optimistically
final messageUserId =
payload.newRecord['sender_user_id'] as String?;
if (messageUserId == currentUserId) {
print(
'[STREAM] Skipping own message in real-time: ${payload.newRecord['id']}',
);
return;
}
// Get complete message data
final messageId = payload.newRecord['id'] as String;
// Get the full message data with user info
final response =
await _supabase
.from('messages')
.select('*, profiles:sender_user_id(*)')
.eq('id', messageId)
.single();
// Create full message data with profile info
final fullMessageData = {
'id': response['id'],
'content': response['content'] ?? '',
'sender_user_id': response['sender_user_id'],
'sender_username':
response['profiles']?['username'] ??
response['sender_username'] ??
'',
'sender_email':
response['profiles']?['email'] ??
response['sender_email'] ??
'',
'created_at': response['created_at'],
'image_url': response['image_url'],
'reply_to_id': response['reply_to_id'],
'reply_to_content': response['reply_to_content'],
'reply_to_sender_email': response['reply_to_sender_email'],
'avatar_url': response['profiles']?['avatar_url'],
'group_id': response['group_id'],
};
// Add force refresh timestamp to image URL to prevent caching
if (fullMessageData['image_url'] != null &&
fullMessageData['image_url'].toString().isNotEmpty) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
fullMessageData['image_url'] =
'${fullMessageData['image_url']}?t=$timestamp';
}
final message = GroupMessage.fromMap(fullMessageData);
// Call the callback
onNewMessage(message);
print('[STREAM] Real-time message processed: ${message.id}');
} catch (e) {
print('[ERROR] Failed to process real-time message: $e');
}
},
)
.onPostgresChanges(
event: PostgresChangeEvent.update,
schema: 'public',
table: 'read_receipts', // Use read_receipts for message read status
callback: (payload) {
final messageId = payload.newRecord['message_id'] as String?;
final userId = payload.newRecord['user_id'] as String?;
if (messageId != null && userId != null) {
onReadStatusUpdate(messageId, userId);
print(
'[STREAM] Read status updated: message=$messageId, user=$userId',
);
}
},
);
_messagesChannel!.subscribe(); // Subscribe to the channel
print('[STREAM] Subscription set up successfully');
// Check if there are any messages via direct query
try {
final count =
await _supabase
.from('messages')
.select('count')
.eq('group_id', groupId)
.single();
final messageCount = count['count'] as int? ?? 0;
print('[DEBUG] No messages found on initial subscription load');
} catch (e) {
print('[ERROR] Error checking message count: $e');
}
} catch (e) {
print('[ERROR] Failed to set up message subscription: $e');
}
}
// Mark messages as read
Future<void> markMessagesAsRead(List<GroupMessage> messages) async {
if (messages.isEmpty || currentUserId == null) return;
try {
// Filter out messages that are already read by current user
final unreadMessages = messages.where((msg) => !msg.isRead).toList();
if (unreadMessages.isEmpty) return;
// Process each message ID individually using the new function
for (final message in unreadMessages) {
try {
// Pastikan ID pesan adalah UUID yang valid
final messageId = message.id;
if (messageId.isEmpty) continue;
// Use the new function name for handling read receipts
await _supabase
.rpc(
'add_message_read_receipt',
params: {
'p_message_id': messageId.toString(), // Ensure it's string
'p_user_id': currentUserId.toString(), // Ensure it's string
},
)
.timeout(
Duration(seconds: 2),
onTimeout: () {
print(
'[WARNING] Timeout marking message as read: $messageId',
);
return null;
},
);
} catch (e) {
print('[ERROR] Failed to mark message ${message.id} as read: $e');
// Lanjutkan ke pesan berikutnya, jangan biarkan satu error menghentikan semua
}
}
} catch (e) {
print('[ERROR] Failed to mark messages as read: $e');
}
}
// Check if a message has been read by all users in the group
bool isMessageReadByAll(GroupMessage message) {
// For now, just check if the current user has read it
// In a real app, you'd check against all group members
return message.isRead;
}
// Clean up resources
void dispose() {
// Clean up subscriptions
_messagesChannel?.unsubscribe();
_messagesChannel = null;
_readStatusTimer?.cancel();
// _messageStreamController.close(); // This line was removed as per the new_code
}
// Upload image
Future<String?> _uploadImage(File imageFile) async {
try {
print('[DEBUG] Starting image upload process');
// Check if file exists
if (!await imageFile.exists()) {
print('[ERROR] Image file does not exist: ${imageFile.path}');
throw Exception('File does not exist');
}
final userId = _supabase.auth.currentUser?.id;
if (userId == null) {
print('[ERROR] No authenticated user found');
throw Exception('User not authenticated');
}
final timestamp = DateTime.now().millisecondsSinceEpoch;
final randomPart = Random().nextInt(10000).toString().padLeft(4, '0');
final filePath = '$userId-$timestamp-$randomPart.jpg';
print('[DEBUG] Generated file path: $filePath');
// Verify file size
final fileSize = await imageFile.length();
print(
'[DEBUG] File size: ${(fileSize / 1024 / 1024).toStringAsFixed(2)} MB',
);
if (fileSize > 5 * 1024 * 1024) {
// 5MB
print('[ERROR] File too large: ${fileSize / 1024 / 1024} MB');
throw Exception('Ukuran gambar terlalu besar (maksimal 5MB)');
}
// Try hardcoded bucket first to simplify the process
try {
print('[DEBUG] Attempting direct upload to images bucket');
await _supabase.storage
.from('images')
.upload(
filePath,
imageFile,
fileOptions: const FileOptions(
cacheControl: '3600',
upsert: true,
),
);
final imageUrl = _supabase.storage
.from('images')
.getPublicUrl(filePath);
print('[DEBUG] Successfully uploaded to images bucket: $imageUrl');
return imageUrl;
} catch (e) {
print('[DEBUG] Direct upload to images bucket failed: $e');
// Fall back to trying multiple buckets
}
// Daftar bucket yang akan dicoba, dalam urutan prioritas
final bucketOptions = [
'images',
'avatars',
'community',
'chat-images',
'public', // Tambahkan bucket public jika ada
];
String? imageUrl;
Exception? lastError;
// Coba setiap bucket sampai berhasil
for (final bucketName in bucketOptions) {
try {
print('[DEBUG] Trying upload to bucket: $bucketName');
await _supabase.storage
.from(bucketName)
.upload(
filePath,
imageFile,
fileOptions: const FileOptions(
cacheControl: '3600',
upsert: true,
),
);
imageUrl = _supabase.storage.from(bucketName).getPublicUrl(filePath);
print(
'[DEBUG] Successfully uploaded to bucket $bucketName: $imageUrl',
);
return imageUrl;
} catch (e) {
print('[DEBUG] Failed to upload to bucket $bucketName: $e');
lastError = e as Exception;
// Continue to next bucket
}
}
// If all buckets failed
if (lastError != null) {
throw lastError;
}
return null;
} catch (e) {
print('[ERROR] Image upload failed: $e');
rethrow;
}
}
}
// Helper class to return messages with pagination info
class MessageResult {
final List<GroupMessage> messages;
final bool hasMore;
MessageResult({required this.messages, required this.hasMore});
}