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 _profilePictureCache = {}; String? get currentUserId => _supabase.auth.currentUser?.id; // Load messages for a specific group Future loadMessages( String groupId, { bool forceRefresh = false, bool loadNew = false, List 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 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 loadMoreMessages( String groupId, List 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 _parseMessages(List response) { return response .map((data) { // Check if this is a JSON string from RPC function final Map 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.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() .toList(); } // Send message to group Future 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 timestamp = DateTime.now().millisecondsSinceEpoch; final messageId = 'grp-$timestamp-${userId.substring(0, userId.length.clamp(0, 6))}'; 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('group_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.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('group_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 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 markMessagesAsRead(List 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, 'p_user_id': currentUserId}, ) .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 _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 messages; final bool hasMore; MessageResult({required this.messages, required this.hasMore}); }