1258 lines
38 KiB
Dart
1258 lines
38 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'dart:async';
|
|
import 'package:tugas_akhir_supabase/core/theme/app_colors.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/models/group.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/models/group_message.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/models/message.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/services/group_message_service.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/services/group_service.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/services/profile_service.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'dart:io';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
import 'package:tugas_akhir_supabase/screens/community/components/message_item.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/components/message_input.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/components/empty_state.dart';
|
|
import 'package:tugas_akhir_supabase/screens/shared/leaf_pattern_painter.dart';
|
|
|
|
class GroupChatScreen extends StatefulWidget {
|
|
final String groupId;
|
|
final bool isInTabView;
|
|
final GlobalKey<GroupChatScreenState>? screenKey;
|
|
|
|
const GroupChatScreen({
|
|
super.key,
|
|
required this.groupId,
|
|
this.isInTabView = false,
|
|
this.screenKey,
|
|
});
|
|
|
|
@override
|
|
GroupChatScreenState createState() => GroupChatScreenState();
|
|
}
|
|
|
|
class GroupChatScreenState extends State<GroupChatScreen>
|
|
with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
|
|
// Services
|
|
final _groupService = GroupService();
|
|
final _messageService = GroupMessageService();
|
|
final _profileService = ProfileService();
|
|
|
|
// Controllers
|
|
final _messageController = TextEditingController();
|
|
final _scrollController = ScrollController();
|
|
final _messageFocusNode = FocusNode();
|
|
final _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
|
|
StreamSubscription? _messageStreamSubscription;
|
|
|
|
// State variables
|
|
bool _isLoading = true;
|
|
bool _isLoadingMore = false;
|
|
bool _hasMoreMessages = true;
|
|
bool _isUploading = false;
|
|
bool _isReplying = false;
|
|
bool _showEmojiKeyboard = false;
|
|
final bool _isSelectMode = false;
|
|
|
|
// Data
|
|
Group? _currentGroup;
|
|
List<GroupMessage> _messages = [];
|
|
GroupMessage? _replyToMessage;
|
|
File? _selectedImage;
|
|
XFile? _selectedImageFile;
|
|
String? _selectedImagePath;
|
|
Set<String> _deletedMessageIds = {};
|
|
final Set<String> _selectedMessageIds = {};
|
|
|
|
// User info
|
|
String? _currentUserEmail;
|
|
String? _currentUsername;
|
|
|
|
// Track if a refresh is in progress
|
|
bool _isRefreshing = false;
|
|
|
|
// Debounced refresh that prevents infinite loops
|
|
void _debouncedRefresh() {
|
|
if (_isRefreshing) {
|
|
print('[DEBUG] Refresh already in progress, skipping');
|
|
return;
|
|
}
|
|
|
|
_isRefreshing = true;
|
|
print('[FORCE] Performing debounced refresh');
|
|
|
|
// Do a single aggressive refresh
|
|
_loadMessages(forceRefresh: true);
|
|
|
|
// Reset flag after some time
|
|
Future.delayed(Duration(seconds: 5), () {
|
|
if (mounted) {
|
|
_isRefreshing = false;
|
|
print('[DEBUG] Refresh cooldown complete');
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initialize();
|
|
}
|
|
|
|
void _initialize() async {
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_messageController.addListener(_updateSendButtonState);
|
|
_scrollController.addListener(_scrollListener);
|
|
|
|
// Immediately load messages without waiting for other initialization
|
|
if (mounted) {
|
|
_loadMessages(forceRefresh: true).catchError((e) {
|
|
print('[ERROR] Initial message load failed: $e');
|
|
});
|
|
}
|
|
|
|
// Subscribe to real-time message updates
|
|
_setupStreamSubscription();
|
|
_safeSetupMessagesSubscription();
|
|
|
|
// Gunakan try-catch untuk menangkap error dan mencegah crash
|
|
try {
|
|
// Set safety timer untuk mencegah loading yang tidak berhenti
|
|
Future.delayed(Duration(seconds: 5), () {
|
|
if (mounted && _isLoading) {
|
|
print('[WARNING] Force completing group chat loading after timeout');
|
|
setState(() => _isLoading = false);
|
|
}
|
|
});
|
|
|
|
// Inisialisasi dengan timeout untuk mencegah blocking
|
|
await _profileService.initialize().timeout(
|
|
Duration(seconds: 2),
|
|
onTimeout: () {
|
|
print('[WARNING] Profile service initialization timed out');
|
|
throw TimeoutException('Profile service initialization timed out');
|
|
},
|
|
);
|
|
|
|
await _getCurrentUser().timeout(
|
|
Duration(seconds: 2),
|
|
onTimeout: () {
|
|
print('[WARNING] Get current user timed out');
|
|
// Continue anyway
|
|
},
|
|
);
|
|
|
|
await _loadGroupDetails().timeout(
|
|
Duration(seconds: 2),
|
|
onTimeout: () {
|
|
print('[WARNING] Load group details timed out');
|
|
// Continue anyway
|
|
},
|
|
);
|
|
|
|
await _loadDeletedMessageIds().timeout(
|
|
Duration(seconds: 2),
|
|
onTimeout: () {
|
|
print('[WARNING] Load deleted message IDs timed out');
|
|
// Continue anyway
|
|
},
|
|
);
|
|
|
|
// Delay subscription setup to prevent blocking
|
|
Future.delayed(Duration(milliseconds: 800), () {
|
|
if (mounted) {
|
|
_safeSetupMessagesSubscription();
|
|
}
|
|
});
|
|
} catch (e) {
|
|
print('[ERROR] Error in group chat initialization: $e');
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set up subscription to real-time message stream
|
|
void _setupStreamSubscription() {
|
|
_messageStreamSubscription?.cancel();
|
|
// Instead of using messageStream, we'll use the onNewMessage callback in setupMessagesSubscription
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(GroupChatScreen oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
// If group ID changed, reload everything
|
|
if (oldWidget.groupId != widget.groupId) {
|
|
_loadGroupDetails();
|
|
_loadMessages(forceRefresh: true);
|
|
_setupStreamSubscription();
|
|
_safeSetupMessagesSubscription();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_messageController.removeListener(_updateSendButtonState);
|
|
_messageController.dispose();
|
|
_messageFocusNode.dispose();
|
|
_scrollController.removeListener(_scrollListener);
|
|
_scrollController.dispose();
|
|
_messageStreamSubscription?.cancel();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
// User details
|
|
Future<void> _getCurrentUser() async {
|
|
final userInfo = await _profileService.getCurrentUser();
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentUserEmail = userInfo.email;
|
|
_currentUsername = userInfo.username;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Load group details
|
|
Future<void> _loadGroupDetails() async {
|
|
try {
|
|
final group = await _groupService.getGroupDetails(widget.groupId);
|
|
|
|
if (mounted && group != null) {
|
|
setState(() {
|
|
_currentGroup = group;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to load group details: $e');
|
|
}
|
|
}
|
|
|
|
// Load messages
|
|
Future<void> _loadMessages({
|
|
bool forceRefresh = false,
|
|
bool loadNew = false,
|
|
}) async {
|
|
print(
|
|
'[DEBUG] _loadMessages called. forceRefresh: $forceRefresh, loadNew: $loadNew',
|
|
);
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
if (!loadNew) _isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final result = await _messageService.loadMessages(
|
|
widget.groupId,
|
|
forceRefresh: forceRefresh,
|
|
loadNew: loadNew,
|
|
existingMessages: _messages,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
// Filter out deleted messages
|
|
List<GroupMessage> filteredMessages =
|
|
result.messages
|
|
.where((msg) => !_deletedMessageIds.contains(msg.id))
|
|
.toList();
|
|
|
|
if (loadNew && _messages.isNotEmpty) {
|
|
// Add only new messages that don't already exist
|
|
final existingIds = _messages.map((m) => m.id).toSet();
|
|
final newMessages =
|
|
filteredMessages
|
|
.where((msg) => !existingIds.contains(msg.id))
|
|
.toList();
|
|
|
|
_messages.insertAll(0, newMessages);
|
|
|
|
// Ensure no duplicates and proper sorting
|
|
final uniqueMessages = <String>{};
|
|
_messages =
|
|
_messages.where((msg) {
|
|
final isUnique = !uniqueMessages.contains(msg.id);
|
|
uniqueMessages.add(msg.id);
|
|
return isUnique;
|
|
}).toList();
|
|
} else {
|
|
_messages = filteredMessages;
|
|
}
|
|
|
|
// Always sort after modifying the list
|
|
_messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
|
|
_hasMoreMessages = result.hasMore;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
_showErrorSnackBar(
|
|
'Gagal memuat pesan. Silakan coba lagi.',
|
|
onRetry: () => _loadMessages(forceRefresh: true),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMoreMessages() async {
|
|
if (!mounted || _isLoadingMore || !_hasMoreMessages) return;
|
|
|
|
setState(() => _isLoadingMore = true);
|
|
|
|
try {
|
|
final result = await _messageService.loadMoreMessages(
|
|
widget.groupId,
|
|
_messages,
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
// Filter out deleted messages
|
|
List<GroupMessage> filteredMessages =
|
|
result.messages
|
|
.where((msg) => !_deletedMessageIds.contains(msg.id))
|
|
.toList();
|
|
|
|
// Add only messages that don't already exist
|
|
final existingIds = _messages.map((m) => m.id).toSet();
|
|
final newMessages =
|
|
filteredMessages
|
|
.where((msg) => !existingIds.contains(msg.id))
|
|
.toList();
|
|
|
|
_messages.addAll(newMessages);
|
|
|
|
// Ensure no duplicates
|
|
final uniqueMessages = <String>{};
|
|
_messages =
|
|
_messages.where((msg) {
|
|
final isUnique = !uniqueMessages.contains(msg.id);
|
|
uniqueMessages.add(msg.id);
|
|
return isUnique;
|
|
}).toList();
|
|
|
|
// Always sort after modifying the list
|
|
_messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
|
|
_hasMoreMessages = result.hasMore;
|
|
_isLoadingMore = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _isLoadingMore = false);
|
|
_showErrorSnackBar('Gagal memuat pesan sebelumnya. Silakan coba lagi.');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Message sending
|
|
Future<void> _sendMessage() async {
|
|
final messageText = _messageController.text.trim();
|
|
if (messageText.isEmpty && _selectedImage == null) return;
|
|
|
|
_messageController.clear();
|
|
final imageToUpload = _selectedImage;
|
|
final replyToMessage =
|
|
_replyToMessage != null ? GroupMessage.copy(_replyToMessage!) : null;
|
|
|
|
setState(() {
|
|
_selectedImage = null;
|
|
_replyToMessage = null;
|
|
_isReplying = false;
|
|
_isUploading = imageToUpload != null;
|
|
});
|
|
|
|
// If we have an image, create a local preview message immediately
|
|
if (imageToUpload != null) {
|
|
final previewMessage = GroupMessage(
|
|
id: 'temp-${DateTime.now().millisecondsSinceEpoch}',
|
|
content: messageText,
|
|
senderEmail: _currentUserEmail ?? '',
|
|
senderUsername: _currentUsername ?? '',
|
|
senderUserId: _messageService.currentUserId ?? '',
|
|
createdAt: DateTime.now(),
|
|
groupId: widget.groupId,
|
|
isLocalImage: true,
|
|
localImageFile: imageToUpload,
|
|
);
|
|
|
|
// Add the preview message to the UI immediately
|
|
setState(() {
|
|
_messages.insert(0, previewMessage);
|
|
_messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
});
|
|
}
|
|
|
|
try {
|
|
// The image will be shown immediately with an optimistic update
|
|
final result = await _messageService.sendMessage(
|
|
groupId: widget.groupId,
|
|
text: messageText,
|
|
imageFile: imageToUpload,
|
|
replyToMessage: replyToMessage,
|
|
currentUsername: _currentUsername,
|
|
currentEmail: _currentUserEmail,
|
|
onOptimisticUpdate: (message) {
|
|
if (mounted) {
|
|
setState(() {
|
|
// If this is an image update, find and replace the temp message
|
|
if (imageToUpload != null) {
|
|
final tempIndex = _messages.indexWhere(
|
|
(m) => m.isLocalImage && m.localImageFile == imageToUpload,
|
|
);
|
|
|
|
if (tempIndex >= 0) {
|
|
_messages[tempIndex] = message;
|
|
} else {
|
|
// Check if the message already exists by ID
|
|
final existingIndex = _messages.indexWhere(
|
|
(m) => m.id == message.id,
|
|
);
|
|
|
|
if (existingIndex >= 0) {
|
|
// Update existing message (for image URL updates)
|
|
_messages[existingIndex] = message;
|
|
} else {
|
|
// Add new message
|
|
_messages.insert(0, message);
|
|
}
|
|
}
|
|
} else {
|
|
// For text messages, just add or update
|
|
final existingIndex = _messages.indexWhere(
|
|
(m) => m.id == message.id,
|
|
);
|
|
|
|
if (existingIndex >= 0) {
|
|
_messages[existingIndex] = message;
|
|
} else {
|
|
_messages.insert(0, message);
|
|
}
|
|
}
|
|
|
|
// Remove duplicates and sort
|
|
final uniqueIds = <String>{};
|
|
_messages =
|
|
_messages.where((msg) {
|
|
final isUnique = !uniqueIds.contains(msg.id);
|
|
uniqueIds.add(msg.id);
|
|
return isUnique;
|
|
}).toList();
|
|
|
|
_messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Message was sent successfully
|
|
// The optimistic update already handled UI updates
|
|
|
|
// If the message had an image, make sure we have the final version with the correct URL
|
|
if (imageToUpload != null) {
|
|
setState(() {
|
|
_isUploading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _isUploading = false);
|
|
_showErrorSnackBar(
|
|
'Pesan gagal terkirim. Coba lagi nanti.',
|
|
onRetry: () => _sendMessage(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read status
|
|
void _markVisibleMessagesAsRead() {
|
|
_messageService.markMessagesAsRead(_messages);
|
|
}
|
|
|
|
void _updateReadStatus() {
|
|
_markVisibleMessagesAsRead();
|
|
}
|
|
|
|
// New message handler
|
|
void _onNewMessage(GroupMessage message) {
|
|
if (!mounted) return;
|
|
|
|
// Check if we already have this message
|
|
final existingIndex = _messages.indexWhere((m) => m.id == message.id);
|
|
|
|
setState(() {
|
|
if (existingIndex >= 0) {
|
|
// Update existing message (e.g., when image URL is updated)
|
|
_messages[existingIndex] = message;
|
|
} else {
|
|
// Check if message is deleted
|
|
if (!_deletedMessageIds.contains(message.id)) {
|
|
// Insert without duplicating
|
|
_messages.insert(0, message);
|
|
}
|
|
}
|
|
|
|
// Remove duplicates by ID before sorting
|
|
final uniqueMessages = <String>{};
|
|
_messages =
|
|
_messages.where((msg) {
|
|
final isUnique = !uniqueMessages.contains(msg.id);
|
|
uniqueMessages.add(msg.id);
|
|
return isUnique;
|
|
}).toList();
|
|
|
|
// Sort messages
|
|
_messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
});
|
|
}
|
|
|
|
// Read status update handler
|
|
void _onReadStatusUpdate(String messageId, String userId) {
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
}
|
|
|
|
// Scroll handling
|
|
void _scrollListener() {
|
|
// Load more messages when reaching the bottom
|
|
if (_scrollController.position.pixels >=
|
|
_scrollController.position.maxScrollExtent - 500 &&
|
|
!_isLoadingMore &&
|
|
_hasMoreMessages) {
|
|
_loadMoreMessages();
|
|
}
|
|
|
|
// Mark visible messages as read
|
|
_markVisibleMessagesAsRead();
|
|
}
|
|
|
|
// UI updates
|
|
void _updateSendButtonState() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
// Reply
|
|
void _startReply(Message message) {
|
|
// Convert Message to GroupMessage if needed
|
|
final groupMessage =
|
|
message is GroupMessage
|
|
? message
|
|
: GroupMessage.fromMessage(message, widget.groupId);
|
|
|
|
// Check message age
|
|
final cutoffDate = DateTime.now().subtract(Duration(days: 30));
|
|
if (message.createdAt.isBefore(cutoffDate)) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Tidak dapat membalas pesan yang lebih lama dari 30 hari',
|
|
),
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Give feedback
|
|
HapticFeedback.lightImpact();
|
|
|
|
setState(() {
|
|
_replyToMessage = groupMessage;
|
|
_isReplying = true;
|
|
_showEmojiKeyboard = false;
|
|
});
|
|
|
|
// Focus the input field after a short delay
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
Future.delayed(Duration(milliseconds: 100), () {
|
|
if (mounted) FocusScope.of(context).requestFocus(_messageFocusNode);
|
|
});
|
|
}
|
|
});
|
|
} catch (e) {
|
|
print('[ERROR] Error starting reply: $e');
|
|
setState(() {
|
|
_replyToMessage = null;
|
|
_isReplying = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _cancelReply() {
|
|
setState(() {
|
|
_replyToMessage = null;
|
|
_isReplying = false;
|
|
});
|
|
}
|
|
|
|
// Media
|
|
void _showImageSourceOptions() {
|
|
if (kIsWeb) {
|
|
// On web, only show option to pick from gallery since camera is not supported
|
|
_pickImage();
|
|
return;
|
|
}
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
builder:
|
|
(context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: Icon(Icons.camera_alt, color: AppColors.primary),
|
|
title: Text('Camera'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_pickImageFromCamera();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(Icons.photo_library, color: AppColors.primary),
|
|
title: Text('Gallery'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_pickImage();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickImage() async {
|
|
try {
|
|
final ImagePicker picker = ImagePicker();
|
|
final XFile? image = await picker.pickImage(
|
|
source: ImageSource.gallery,
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
imageQuality: 85,
|
|
);
|
|
|
|
if (image != null && mounted) {
|
|
if (kIsWeb) {
|
|
// For web, we need special handling
|
|
setState(() {
|
|
_selectedImageFile = image;
|
|
_selectedImagePath = 'Image selected';
|
|
_selectedImage = null; // Clear File-based image
|
|
});
|
|
} else {
|
|
// For mobile platforms
|
|
setState(() {
|
|
_selectedImageFile = image;
|
|
_selectedImagePath = image.path;
|
|
_selectedImage = File(image.path);
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Failed to pick image: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Failed to select image: $e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _pickImageFromCamera() async {
|
|
final ImagePicker picker = ImagePicker();
|
|
try {
|
|
final XFile? image = await picker.pickImage(
|
|
source: ImageSource.camera,
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
imageQuality: 85,
|
|
);
|
|
|
|
if (image != null && mounted) {
|
|
setState(() => _selectedImage = File(image.path));
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Gagal mengambil gambar dari kamera: $e');
|
|
}
|
|
}
|
|
|
|
// Load deleted message IDs from local storage
|
|
Future<void> _loadDeletedMessageIds() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final deletedIds =
|
|
prefs.getStringList('deleted_message_ids_${widget.groupId}') ?? [];
|
|
setState(() {
|
|
_deletedMessageIds = Set<String>.from(deletedIds);
|
|
});
|
|
} catch (e) {
|
|
print('[ERROR] Failed to load deleted message IDs: $e');
|
|
}
|
|
}
|
|
|
|
// Save deleted message IDs to local storage
|
|
Future<void> _saveDeletedMessageIds() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setStringList(
|
|
'deleted_message_ids_${widget.groupId}',
|
|
_deletedMessageIds.toList(),
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to save deleted message IDs: $e');
|
|
}
|
|
}
|
|
|
|
void _showErrorSnackBar(String message, {VoidCallback? onRetry}) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
backgroundColor: Colors.red,
|
|
duration: Duration(seconds: 3),
|
|
action:
|
|
onRetry != null
|
|
? SnackBarAction(label: 'Coba Lagi', onPressed: onRetry)
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openLink(LinkableElement link) async {
|
|
final url = Uri.parse(link.url);
|
|
|
|
try {
|
|
if (await canLaunchUrl(url)) {
|
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Tidak dapat membuka: ${link.url}')),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Gagal membuka URL: $e');
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Gagal membuka URL: $e')));
|
|
}
|
|
}
|
|
|
|
bool _shouldShowDateHeader(int index) {
|
|
if (index == _messages.length - 1) {
|
|
return true; // Always show for first message (which is last in the list)
|
|
}
|
|
|
|
if (index < _messages.length - 1) {
|
|
final currentDate = DateTime(
|
|
_messages[index].createdAt.year,
|
|
_messages[index].createdAt.month,
|
|
_messages[index].createdAt.day,
|
|
);
|
|
|
|
final previousDate = DateTime(
|
|
_messages[index + 1].createdAt.year,
|
|
_messages[index + 1].createdAt.month,
|
|
_messages[index + 1].createdAt.day,
|
|
);
|
|
|
|
return currentDate != previousDate;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
Widget _buildDateHeader(DateTime date) {
|
|
final now = DateTime.now();
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
final yesterday = today.subtract(const Duration(days: 1));
|
|
final messageDate = DateTime(date.year, date.month, date.day);
|
|
|
|
String dateText;
|
|
if (messageDate == today) {
|
|
dateText = 'Hari ini';
|
|
} else if (messageDate == yesterday) {
|
|
dateText = 'Kemarin';
|
|
} else {
|
|
dateText = DateFormat('EEEE, d MMMM y', 'id_ID').format(messageDate);
|
|
}
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[300],
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Text(
|
|
dateText,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[800],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return WillPopScope(
|
|
onWillPop: () async {
|
|
// Pastikan keyboard ditutup sebelum kembali
|
|
if (_showEmojiKeyboard) {
|
|
setState(() => _showEmojiKeyboard = false);
|
|
}
|
|
return true;
|
|
},
|
|
child: Scaffold(
|
|
appBar:
|
|
widget.isInTabView
|
|
? null
|
|
: AppBar(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
title:
|
|
_currentGroup != null
|
|
? Text(_currentGroup!.name)
|
|
: const Text('Diskusi Grup'),
|
|
elevation: 0,
|
|
),
|
|
body: Column(
|
|
children: [
|
|
// Messages area
|
|
Expanded(
|
|
child: GestureDetector(
|
|
// Dismiss keyboard when tapping outside of text field
|
|
onTap: () {
|
|
if (_showEmojiKeyboard) {
|
|
setState(() => _showEmojiKeyboard = false);
|
|
}
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
// Agriculture-themed background
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.green[50]!,
|
|
Colors.green[100]!.withOpacity(0.3),
|
|
],
|
|
),
|
|
),
|
|
child: CustomPaint(
|
|
painter: LeafPatternPainter(),
|
|
child: Container(),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Messages content
|
|
RefreshIndicator(
|
|
key: _refreshIndicatorKey,
|
|
color: AppColors.primary,
|
|
onRefresh: () => _loadMessages(forceRefresh: true),
|
|
child: _buildMessageArea(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Input area
|
|
_buildMessageInput(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageArea() {
|
|
if (_isLoading) {
|
|
return Center(
|
|
child: CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
|
|
),
|
|
);
|
|
} else if (_messages.isEmpty) {
|
|
return EmptyStateWidget(
|
|
onTap: () {
|
|
print('[FORCE] EmptyState onTap triggered');
|
|
|
|
// Use the debounced refresh
|
|
_debouncedRefresh();
|
|
|
|
// Try to show the RefreshIndicator directly, but only once
|
|
if (_refreshIndicatorKey.currentState != null && !_isRefreshing) {
|
|
_refreshIndicatorKey.currentState!.show();
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
return _buildMessageList();
|
|
}
|
|
}
|
|
|
|
Widget _buildMessageList() {
|
|
return ListView.builder(
|
|
key: PageStorageKey<String>('group_message_list_${widget.groupId}'),
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
|
itemCount: _messages.length + (_isLoadingMore ? 1 : 0),
|
|
reverse: true,
|
|
cacheExtent: 1000, // Keep more items in memory
|
|
addAutomaticKeepAlives: true,
|
|
itemBuilder: (context, index) {
|
|
// Show loading indicator at the end of the list
|
|
if (index == _messages.length) {
|
|
return const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16.0),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
);
|
|
}
|
|
|
|
final message = _messages[index];
|
|
final isMyMessage =
|
|
message.senderUserId == _messageService.currentUserId;
|
|
|
|
// Check if we need to show date header
|
|
final showDateHeader = _shouldShowDateHeader(index);
|
|
|
|
// Use a more efficient approach to rebuild only when needed
|
|
return Column(
|
|
children: [
|
|
if (showDateHeader) _buildDateHeader(_messages[index].createdAt),
|
|
MessageItem(
|
|
key: ValueKey<String>(message.id),
|
|
message: message,
|
|
isMyMessage: isMyMessage,
|
|
isReadByAll: _messageService.isMessageReadByAll(message),
|
|
onReply: _startReply,
|
|
onLongPress: _showMessageOptions,
|
|
onOpenLink: (link) => _openLink(link),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showMessageOptions(Message message) {
|
|
final bool isMyMessage =
|
|
message.senderUserId == _messageService.currentUserId;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
builder:
|
|
(context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: Icon(Icons.reply, color: Colors.green.shade700),
|
|
title: const Text('Balas'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_startReply(message);
|
|
},
|
|
),
|
|
// Only show delete options for user's own messages
|
|
if (isMyMessage)
|
|
ListTile(
|
|
leading: Icon(Icons.delete_forever, color: Colors.red),
|
|
title: const Text('Hapus untuk semua orang'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showDeleteConfirmation(message);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDeleteConfirmation(Message message) {
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: Text('Konfirmasi Hapus'),
|
|
content: Text(
|
|
'Pesan ini akan dihapus untuk semua orang. Lanjutkan?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('Batal'),
|
|
),
|
|
TextButton(
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_deleteMessage(message);
|
|
},
|
|
child: Text('Hapus'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteMessage(Message message) async {
|
|
// First remove from local list for immediate feedback
|
|
setState(() {
|
|
_messages.removeWhere((m) => m.id == message.id);
|
|
_deletedMessageIds.add(message.id);
|
|
});
|
|
|
|
_saveDeletedMessageIds();
|
|
|
|
try {
|
|
// Convert to GroupMessage if needed
|
|
final groupMessage =
|
|
message is GroupMessage
|
|
? message
|
|
: GroupMessage.fromMessage(message, widget.groupId);
|
|
|
|
await _messageService.deleteMessage(groupMessage);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Pesan dihapus'),
|
|
backgroundColor: Colors.green,
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to delete message: $e');
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Gagal menghapus pesan dari server: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildMessageInput() {
|
|
return MessageInputWidget(
|
|
messageController: _messageController,
|
|
focusNode: _messageFocusNode,
|
|
isUploading: _isUploading,
|
|
selectedImage: _selectedImage,
|
|
showEmojiKeyboard: _showEmojiKeyboard,
|
|
isReplying: _isReplying,
|
|
replyToMessage: _replyToMessage,
|
|
onSend: _sendMessage,
|
|
onImageOptions: _showImageSourceOptions,
|
|
themeColor: AppColors.primary,
|
|
onEmojiToggle: () {
|
|
setState(() {
|
|
_showEmojiKeyboard = !_showEmojiKeyboard;
|
|
if (_showEmojiKeyboard) {
|
|
_messageFocusNode.unfocus();
|
|
} else {
|
|
_messageFocusNode.requestFocus();
|
|
}
|
|
});
|
|
},
|
|
onClearImage: () {
|
|
setState(() => _selectedImage = null);
|
|
},
|
|
onCancelReply: _cancelReply,
|
|
);
|
|
}
|
|
|
|
// Public method to force refresh
|
|
void forceRefresh() {
|
|
print('[DEBUG] Force refresh called on GroupChatScreen');
|
|
|
|
// Make sure we're not already refreshing
|
|
if (_isLoading) {
|
|
print('[DEBUG] Already loading, skipping additional refresh');
|
|
return;
|
|
}
|
|
|
|
// Aggressively load messages with forceRefresh
|
|
_loadMessages(forceRefresh: true);
|
|
|
|
// Additional refresh after a short delay for images
|
|
Future.delayed(Duration(milliseconds: 500), () {
|
|
if (mounted) {
|
|
setState(() {
|
|
// Force UI update even if data hasn't changed
|
|
// This ensures image cache is properly refreshed
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Emergency direct database access to bypass service layer
|
|
void _emergencyLoadMessages() async {
|
|
try {
|
|
print(
|
|
'[FORCE] Emergency direct message load for group: ${widget.groupId}',
|
|
);
|
|
final supabase = Supabase.instance.client;
|
|
|
|
// Try to load directly from database
|
|
final response = await supabase
|
|
.from('messages_with_sender')
|
|
.select()
|
|
.eq('group_id', widget.groupId)
|
|
.order('created_at', ascending: false)
|
|
.limit(20);
|
|
|
|
print('[FORCE] Got ${response.length} messages directly from database');
|
|
|
|
if (mounted && response.isNotEmpty) {
|
|
// Process messages
|
|
final messages =
|
|
response.map((data) {
|
|
return GroupMessage.fromMap({
|
|
'id': data['id'],
|
|
'content': data['content'] ?? '',
|
|
'sender_user_id': data['sender_user_id'] ?? '',
|
|
'sender_username':
|
|
data['profile_username'] ?? data['sender_username'] ?? '',
|
|
'sender_email':
|
|
data['profile_email'] ?? data['sender_email'] ?? '',
|
|
'created_at': data['created_at'],
|
|
'image_url': data['image_url'],
|
|
'reply_to_id': data['reply_to_id'],
|
|
'reply_to_content': data['reply_to_content'],
|
|
'reply_to_sender_email': data['reply_to_sender_email'],
|
|
'avatar_url': data['avatar_url'],
|
|
'group_id': data['group_id'],
|
|
});
|
|
}).toList();
|
|
|
|
// Update UI with messages
|
|
setState(() {
|
|
if (_messages.isEmpty) {
|
|
_messages = messages.toList();
|
|
_messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
}
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Emergency load failed: $e');
|
|
}
|
|
}
|
|
|
|
// Set up message subscriptions safely with timeout
|
|
void _safeSetupMessagesSubscription() {
|
|
print('[DEBUG] _safeSetupMessagesSubscription called');
|
|
try {
|
|
// Set up real-time message subscription with timeout
|
|
_messageService.setupMessagesSubscription(
|
|
widget.groupId,
|
|
_onNewMessage,
|
|
_onReadStatusUpdate,
|
|
);
|
|
|
|
// Also set up the stream listener
|
|
_setupStreamSubscription();
|
|
|
|
// Mark messages as read on initial load
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_markVisibleMessagesAsRead();
|
|
});
|
|
} catch (e) {
|
|
print('[ERROR] Error setting up message subscription: $e');
|
|
}
|
|
}
|
|
|
|
// Set up real time subscription explicitly
|
|
void setupRealTimeSubscription() {
|
|
print('[STREAM] Setting up real-time subscription from parent call');
|
|
_safeSetupMessagesSubscription();
|
|
_setupStreamSubscription();
|
|
}
|
|
|
|
// Clean up resources explicitly when called from parent
|
|
void disposeResources() {
|
|
print('[STREAM] Cleaning up resources from parent call');
|
|
_messageStreamSubscription?.cancel();
|
|
}
|
|
|
|
// Set preloaded messages directly from the parent component
|
|
void setPreloadedMessages(List<dynamic> messageData) {
|
|
if (!mounted) return;
|
|
|
|
try {
|
|
print('[DIRECT] Setting ${messageData.length} preloaded messages');
|
|
|
|
final messages =
|
|
messageData.map((data) {
|
|
return GroupMessage.fromMap({
|
|
'id': data['id'],
|
|
'content': data['content'] ?? '',
|
|
'sender_user_id': data['sender_user_id'] ?? '',
|
|
'sender_username':
|
|
data['profile_username'] ?? data['sender_username'] ?? '',
|
|
'sender_email':
|
|
data['profile_email'] ?? data['sender_email'] ?? '',
|
|
'created_at': data['created_at'],
|
|
'image_url': data['image_url'],
|
|
'reply_to_id': data['reply_to_id'],
|
|
'reply_to_content': data['reply_to_content'],
|
|
'reply_to_sender_email': data['reply_to_sender_email'],
|
|
'avatar_url': data['avatar_url'],
|
|
'group_id': data['group_id'],
|
|
});
|
|
}).toList();
|
|
|
|
setState(() {
|
|
if (_messages.isEmpty) {
|
|
_messages = messages.toList();
|
|
_messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
_isLoading = false;
|
|
print(
|
|
'[DIRECT] Successfully set ${_messages.length} preloaded messages',
|
|
);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
print('[ERROR] Failed to set preloaded messages: $e');
|
|
}
|
|
}
|
|
}
|