1425 lines
42 KiB
Dart
1425 lines
42 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'dart:math';
|
|
import 'dart:async';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'dart:io';
|
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
// Import separated components
|
|
import 'package:tugas_akhir_supabase/screens/community/models/message.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/reply_bar.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/components/empty_state.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/components/search_app_bar.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/services/message_service.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/services/profile_service.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/utils/message_utils.dart';
|
|
import 'package:tugas_akhir_supabase/screens/shared/leaf_pattern_painter.dart';
|
|
import 'package:tugas_akhir_supabase/core/theme/app_colors.dart';
|
|
|
|
class CommunityScreen extends StatefulWidget {
|
|
final bool isInTabView;
|
|
final String? groupId;
|
|
|
|
const CommunityScreen({super.key, this.isInTabView = false, this.groupId});
|
|
|
|
@override
|
|
_CommunityScreenState createState() => _CommunityScreenState();
|
|
}
|
|
|
|
class _CommunityScreenState extends State<CommunityScreen>
|
|
with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
|
|
// Services
|
|
final _messageService = MessageService();
|
|
final _profileService = ProfileService();
|
|
|
|
// Controllers
|
|
final _messageController = TextEditingController();
|
|
final _searchController = TextEditingController();
|
|
final _scrollController = ScrollController();
|
|
final _messageFocusNode = FocusNode();
|
|
final _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
|
|
|
|
// State variables
|
|
bool _isLoading = true;
|
|
bool _isLoadingMore = false;
|
|
bool _isLoadingSearch = false;
|
|
bool _hasMoreMessages = true;
|
|
bool _isUploading = false;
|
|
bool _isSearching = false;
|
|
bool _isReplying = false;
|
|
bool _showEmojiKeyboard = false;
|
|
bool _isSelectMode = false;
|
|
|
|
// Data
|
|
List<Message> _messages = [];
|
|
List<Message> _searchResults = [];
|
|
Message? _replyToMessage;
|
|
File? _selectedImage;
|
|
Set<String> _deletedMessageIds = {};
|
|
Set<String> _selectedMessageIds = {};
|
|
|
|
// User info
|
|
String? _currentUserEmail;
|
|
String? _currentUsername;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initialize();
|
|
}
|
|
|
|
void _initialize() async {
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_messageController.addListener(_updateSendButtonState);
|
|
_scrollController.addListener(_scrollListener);
|
|
|
|
await _profileService.initialize();
|
|
await _getCurrentUser();
|
|
await _loadDeletedMessageIds();
|
|
await _loadMessages();
|
|
|
|
_setupTimers();
|
|
}
|
|
|
|
void _setupTimers() {
|
|
// Set up message subscription with slight delay to prevent UI blocking
|
|
Future.delayed(Duration(milliseconds: 500), () {
|
|
if (mounted)
|
|
_messageService.setupMessagesSubscription(
|
|
_onNewMessage,
|
|
_onReadStatusUpdate,
|
|
);
|
|
});
|
|
|
|
// Setup periodic refresh timer
|
|
_messageService.setupRefreshTimer(
|
|
onRefresh: () {
|
|
if (mounted) _loadMessages(loadNew: true);
|
|
},
|
|
);
|
|
|
|
// Setup read status timer
|
|
_messageService.setupReadStatusTimer(
|
|
onUpdate: () {
|
|
if (mounted) _updateReadStatus();
|
|
},
|
|
);
|
|
|
|
// Mark messages as read on initial load
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_markVisibleMessagesAsRead();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
_updateReadStatus();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_messageController.removeListener(_updateSendButtonState);
|
|
_messageController.dispose();
|
|
_searchController.dispose();
|
|
_messageFocusNode.dispose();
|
|
_scrollController.removeListener(_scrollListener);
|
|
_scrollController.dispose();
|
|
|
|
_messageService.dispose();
|
|
_profileService.dispose();
|
|
|
|
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 messages
|
|
Future<void> _loadMessages({
|
|
bool forceRefresh = false,
|
|
bool loadNew = false,
|
|
}) async {
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
if (!loadNew) _isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final result = await _messageService.loadMessages(
|
|
forceRefresh: forceRefresh,
|
|
loadNew: loadNew,
|
|
existingMessages: _messages,
|
|
groupId: widget.groupId,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
// Filter out deleted messages
|
|
List<Message> 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(_messages);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
// Filter out deleted messages
|
|
List<Message> 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
|
|
? Message.copy(
|
|
_replyToMessage!,
|
|
) // Create deep copy to avoid reference issues
|
|
: null;
|
|
|
|
setState(() {
|
|
_selectedImage = null;
|
|
_replyToMessage = null;
|
|
_isReplying = false;
|
|
_isUploading = true;
|
|
});
|
|
|
|
try {
|
|
final result = await _messageService.sendMessage(
|
|
text: messageText,
|
|
imageFile: imageToUpload,
|
|
replyToMessage: replyToMessage,
|
|
currentUsername: _currentUsername,
|
|
currentEmail: _currentUserEmail,
|
|
onOptimisticUpdate: (message) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_messages.insert(0, message);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
if (!result.success) {
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
result.errorMessage ?? 'Pesan gagal terkirim. Coba lagi nanti.',
|
|
onRetry: () => _sendMessage(),
|
|
);
|
|
}
|
|
}
|
|
// Success case - no need to show a notification
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_showErrorSnackBar(
|
|
'Pesan gagal terkirim: ${e.toString()}',
|
|
onRetry: () => _sendMessage(),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isUploading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read status
|
|
void _markVisibleMessagesAsRead() {
|
|
_messageService.markVisibleMessagesAsRead(_messages);
|
|
}
|
|
|
|
void _updateReadStatus() {
|
|
_markVisibleMessagesAsRead();
|
|
_messageService.fetchReadReceipts(_messages);
|
|
}
|
|
|
|
// New message handler
|
|
void _onNewMessage(Message message) {
|
|
if (!mounted) return;
|
|
|
|
// Check if we already have this message
|
|
final existingIndex = _messages.indexWhere((m) => m.id == message.id);
|
|
if (existingIndex >= 0) return;
|
|
|
|
// Check if message is deleted
|
|
if (_deletedMessageIds.contains(message.id)) return;
|
|
|
|
setState(() {
|
|
// 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(() {});
|
|
}
|
|
|
|
// Search
|
|
Future<void> _searchMessages() async {
|
|
final query = _searchController.text.trim();
|
|
if (query.isEmpty) {
|
|
setState(() {
|
|
_isSearching = false;
|
|
_searchResults = [];
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() => _isLoadingSearch = true);
|
|
|
|
try {
|
|
final results = await _messageService.searchMessages(query);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_searchResults = results;
|
|
_isLoadingSearch = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _isLoadingSearch = false);
|
|
_showErrorSnackBar('Gagal mencari pesan: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
void _exitSearchMode() {
|
|
setState(() {
|
|
_isSearching = false;
|
|
_searchResults = [];
|
|
_searchController.clear();
|
|
});
|
|
}
|
|
|
|
// Reply
|
|
void _startReply(Message message) {
|
|
// 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 = Message.copy(message); // Create deep copy
|
|
_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
|
|
Future<void> _pickImage() async {
|
|
final ImagePicker picker = ImagePicker();
|
|
try {
|
|
final XFile? image = await picker.pickImage(
|
|
source: ImageSource.gallery,
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
imageQuality: 85,
|
|
);
|
|
|
|
if (image != null && mounted) {
|
|
setState(() => _selectedImage = File(image.path));
|
|
}
|
|
} catch (e) {
|
|
print('[ERROR] Gagal mengambil gambar: $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');
|
|
}
|
|
}
|
|
|
|
void _showImageSourceOptions() {
|
|
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: const Color(0xFF00A884),
|
|
),
|
|
title: Text('Kamera'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_pickImageFromCamera();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(
|
|
Icons.photo_library,
|
|
color: const Color(0xFF00A884),
|
|
),
|
|
title: Text('Galeri'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_pickImage();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Error handling
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
// UI Building
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return WillPopScope(
|
|
onWillPop: () async {
|
|
// Exit select mode if active
|
|
if (_isSelectMode) {
|
|
setState(() {
|
|
_isSelectMode = false;
|
|
_selectedMessageIds.clear();
|
|
});
|
|
return false;
|
|
}
|
|
|
|
// Ensure keyboard and emoji picker are dismissed before popping
|
|
if (_showEmojiKeyboard) {
|
|
setState(() => _showEmojiKeyboard = false);
|
|
}
|
|
// Hapus unfocus yang menyebabkan masalah keyboard
|
|
// FocusScope.of(context).unfocus();
|
|
return true;
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: AppColors.scaffoldBackground,
|
|
appBar:
|
|
widget.isInTabView
|
|
? null // Hide AppBar when in tab view
|
|
: (_isSearching
|
|
? _buildSearchAppBar()
|
|
: _isSelectMode
|
|
? _buildSelectModeAppBar()
|
|
: _buildAppBar()),
|
|
// Set resizeToAvoidBottomInset true to ensure the keyboard doesn't overflow
|
|
resizeToAvoidBottomInset: true,
|
|
body: Column(
|
|
children: [
|
|
// Messages area
|
|
Expanded(
|
|
child: GestureDetector(
|
|
// Dismiss keyboard when tapping outside of text field
|
|
onTap: () {
|
|
// Hapus unfocus yang menyebabkan masalah keyboard
|
|
// FocusScope.of(context).unfocus();
|
|
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: AppColors.backgroundGradient,
|
|
),
|
|
),
|
|
child: CustomPaint(
|
|
painter: LeafPatternPainter(),
|
|
child: Container(),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Messages content
|
|
RefreshIndicator(
|
|
key: _refreshIndicatorKey,
|
|
color: AppColors.primary,
|
|
onRefresh:
|
|
() =>
|
|
_isSearching
|
|
? _searchMessages()
|
|
: _loadMessages(forceRefresh: true),
|
|
child: _buildMessageArea(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Input area - Hide when searching or in select mode
|
|
if (!_isSearching && !_isSelectMode) _buildMessageInput(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageArea() {
|
|
if (_isLoading || _isLoadingSearch) {
|
|
return Center(
|
|
child: CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
|
|
),
|
|
);
|
|
} else if (_isSearching) {
|
|
return _buildSearchResults();
|
|
} else if (_messages.isEmpty) {
|
|
return EmptyStateWidget(onTap: () => _loadMessages(forceRefresh: true));
|
|
} else {
|
|
return _buildMessageList();
|
|
}
|
|
}
|
|
|
|
Widget _buildMessageList() {
|
|
return ListView.builder(
|
|
key: PageStorageKey<String>('message_list'),
|
|
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;
|
|
final isSelected = _selectedMessageIds.contains(message.id);
|
|
|
|
// 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),
|
|
GestureDetector(
|
|
onLongPress:
|
|
_isSelectMode
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
_isSelectMode = true;
|
|
_selectedMessageIds.add(message.id);
|
|
});
|
|
HapticFeedback.mediumImpact();
|
|
},
|
|
onTap:
|
|
_isSelectMode
|
|
? () {
|
|
setState(() {
|
|
if (isSelected) {
|
|
_selectedMessageIds.remove(message.id);
|
|
if (_selectedMessageIds.isEmpty) {
|
|
_isSelectMode = false;
|
|
}
|
|
} else {
|
|
_selectedMessageIds.add(message.id);
|
|
}
|
|
});
|
|
}
|
|
: null,
|
|
child: Stack(
|
|
children: [
|
|
MessageItem(
|
|
key: ValueKey<String>(message.id),
|
|
message: message,
|
|
isMyMessage: isMyMessage,
|
|
isReadByAll: _messageService.isMessageReadByAll(message),
|
|
onReply: _isSelectMode ? (_) {} : _startReply,
|
|
onLongPress:
|
|
_isSelectMode
|
|
? (_) {}
|
|
: (msg) => _showMessageOptions(msg),
|
|
onOpenLink: _openLink,
|
|
),
|
|
if (_isSelectMode)
|
|
Positioned(
|
|
top: 8,
|
|
right: isMyMessage ? 8 : null,
|
|
left: isMyMessage ? null : 8,
|
|
child: Container(
|
|
width: 24,
|
|
height: 24,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color:
|
|
isSelected
|
|
? AppColors.primary
|
|
: Colors.grey.shade300,
|
|
border: Border.all(
|
|
color:
|
|
isSelected
|
|
? AppColors.primary
|
|
: Colors.grey.shade400,
|
|
width: 2,
|
|
),
|
|
),
|
|
child:
|
|
isSelected
|
|
? Icon(
|
|
Icons.check,
|
|
color: Colors.white,
|
|
size: 16,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchResults() {
|
|
if (_searchResults.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.search_off, size: 64, color: Colors.grey),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'Tidak ada hasil',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'Coba kata kunci lain',
|
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
itemCount: _searchResults.length,
|
|
itemBuilder: (context, index) {
|
|
final message = _searchResults[index];
|
|
final isMyMessage =
|
|
message.senderUserId == _messageService.currentUserId;
|
|
|
|
return Column(
|
|
children: [
|
|
_buildDateHeader(message.createdAt),
|
|
MessageItem(
|
|
key: ValueKey<String>('search-${message.id}'),
|
|
message: message,
|
|
isMyMessage: isMyMessage,
|
|
isReadByAll: _messageService.isMessageReadByAll(message),
|
|
onReply: _startReply,
|
|
onLongPress: (msg) => _showMessageOptions(msg),
|
|
onOpenLink: _openLink,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildAppBar() {
|
|
return AppBar(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: AppColors.appBarForeground,
|
|
elevation: 0,
|
|
titleSpacing: 0,
|
|
title: Row(
|
|
children: [
|
|
// Add padding to the left of the group icon
|
|
SizedBox(width: 16),
|
|
CircleAvatar(
|
|
backgroundColor: Colors.white24,
|
|
radius: 20,
|
|
child: Icon(Icons.group, color: Colors.white, size: 22),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'TaniSM4RT',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
|
),
|
|
Text(
|
|
'${_getActiveUsersCount()} anggota',
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w400),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
// Select mode icon
|
|
IconButton(
|
|
icon: Icon(Icons.select_all),
|
|
onPressed: () {
|
|
setState(() {
|
|
_isSelectMode = true;
|
|
});
|
|
},
|
|
tooltip: 'Pilih pesan',
|
|
),
|
|
// Search icon
|
|
IconButton(
|
|
icon: Icon(Icons.search),
|
|
onPressed: () {
|
|
setState(() {
|
|
_isSearching = true;
|
|
});
|
|
},
|
|
tooltip: 'Cari pesan',
|
|
),
|
|
// Refresh icon
|
|
IconButton(
|
|
icon: Icon(Icons.refresh),
|
|
onPressed: () {
|
|
// Cancel any active subscriptions
|
|
_messageService.dispose();
|
|
|
|
// Clear messages and reload
|
|
_clearChatData();
|
|
|
|
// Reinitialize subscriptions
|
|
Future.delayed(Duration(milliseconds: 500), () {
|
|
if (mounted) {
|
|
_messageService.setupMessagesSubscription(
|
|
_onNewMessage,
|
|
_onReadStatusUpdate,
|
|
);
|
|
}
|
|
});
|
|
},
|
|
tooltip: 'Refresh pesan',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildSelectModeAppBar() {
|
|
return AppBar(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: AppColors.appBarForeground,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: Icon(Icons.close),
|
|
onPressed: () {
|
|
setState(() {
|
|
_isSelectMode = false;
|
|
_selectedMessageIds.clear();
|
|
});
|
|
},
|
|
),
|
|
title: Text(
|
|
_selectedMessageIds.isEmpty
|
|
? 'Pilih pesan'
|
|
: '${_selectedMessageIds.length} pesan dipilih',
|
|
),
|
|
actions: [
|
|
if (_selectedMessageIds.isNotEmpty) ...[
|
|
IconButton(
|
|
icon: Icon(Icons.delete),
|
|
onPressed: () => _showDeleteSelectedConfirmation(),
|
|
tooltip: 'Hapus pesan yang dipilih',
|
|
),
|
|
],
|
|
IconButton(
|
|
icon: Icon(Icons.select_all),
|
|
onPressed: () => _toggleSelectAll(),
|
|
tooltip: 'Pilih semua',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildSearchAppBar() {
|
|
return AppBar(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: AppColors.appBarForeground,
|
|
elevation: 0,
|
|
automaticallyImplyLeading: false,
|
|
titleSpacing: 0,
|
|
title: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: _exitSearchMode,
|
|
),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Cari pesan...',
|
|
hintStyle: TextStyle(color: Colors.white70),
|
|
border: InputBorder.none,
|
|
),
|
|
style: TextStyle(color: Colors.black),
|
|
autofocus: true,
|
|
textInputAction: TextInputAction.search,
|
|
onSubmitted: (_) => _searchMessages(),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(Icons.clear),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(icon: Icon(Icons.search), onPressed: _searchMessages),
|
|
],
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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_outline, color: Colors.red),
|
|
// title: const Text('Hapus untuk saya'),
|
|
// onTap: () {
|
|
// Navigator.pop(context);
|
|
// _deleteMessageForMe(message);
|
|
// },
|
|
// ),
|
|
ListTile(
|
|
leading: Icon(Icons.delete_forever, color: Colors.red),
|
|
title: const Text('Hapus untuk semua orang'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showDeleteConfirmation(message, forEveryone: true);
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDeleteConfirmation(Message message, {required bool forEveryone}) {
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: Text('Konfirmasi Hapus'),
|
|
content: Text(
|
|
forEveryone
|
|
? 'Pesan ini akan dihapus untuk semua orang. Lanjutkan?'
|
|
: 'Pesan ini akan dihapus hanya untuk Anda. Lanjutkan?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('Batal'),
|
|
),
|
|
TextButton(
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
if (forEveryone) {
|
|
_deleteMessageForEveryone(message);
|
|
} else {
|
|
_deleteMessageForMe(message);
|
|
}
|
|
},
|
|
child: Text('Hapus'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _deleteMessageForMe(Message message) {
|
|
setState(() {
|
|
_messages.removeWhere((m) => m.id == message.id);
|
|
_deletedMessageIds.add(message.id);
|
|
});
|
|
|
|
_saveDeletedMessageIds();
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Pesan dihapus'),
|
|
backgroundColor: Colors.green,
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteMessageForEveryone(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 {
|
|
await _messageService.deleteMessage(message);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Pesan dihapus untuk semua orang'),
|
|
backgroundColor: Colors.green,
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to delete message for everyone: $e');
|
|
|
|
// Don't reload messages, just show error
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Gagal menghapus pesan dari server: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _clearChatData() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_messages.clear();
|
|
_hasMoreMessages = true;
|
|
});
|
|
|
|
await _loadMessages(forceRefresh: true);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
int _getActiveUsersCount() {
|
|
if (_messages.isEmpty) return 0;
|
|
|
|
final uniqueUserIds = <String>{};
|
|
for (final message in _messages) {
|
|
uniqueUserIds.add(message.senderUserId);
|
|
}
|
|
return uniqueUserIds.length;
|
|
}
|
|
|
|
// Load deleted message IDs from local storage
|
|
Future<void> _loadDeletedMessageIds() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final deletedIds = prefs.getStringList('deleted_message_ids') ?? [];
|
|
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',
|
|
_deletedMessageIds.toList(),
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to save deleted message IDs: $e');
|
|
}
|
|
}
|
|
|
|
// Select mode methods
|
|
void _toggleSelectAll() {
|
|
setState(() {
|
|
if (_selectedMessageIds.length == _messages.length) {
|
|
// If all are selected, unselect all
|
|
_selectedMessageIds.clear();
|
|
} else {
|
|
// Select all messages
|
|
_selectedMessageIds = _messages.map((m) => m.id).toSet();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _showDeleteSelectedConfirmation() {
|
|
final currentUserId = _messageService.currentUserId;
|
|
final allMine = _selectedMessageIds.every((id) {
|
|
final message = _messages.firstWhere((m) => m.id == id);
|
|
return message.senderUserId == currentUserId;
|
|
});
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: Text('Konfirmasi Hapus'),
|
|
content: Text(
|
|
allMine
|
|
? 'Hapus ${_selectedMessageIds.length} pesan yang dipilih?\n\nAnda dapat menghapus untuk semua orang karena semua pesan adalah milik Anda.'
|
|
: 'Hapus ${_selectedMessageIds.length} pesan yang dipilih?\n\nPesan hanya akan dihapus untuk Anda.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('Batal'),
|
|
),
|
|
TextButton(
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
if (allMine) {
|
|
_showDeleteForEveryoneOption();
|
|
} else {
|
|
_deleteSelectedMessages(forEveryone: false);
|
|
}
|
|
},
|
|
child: Text('Hapus'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDeleteForEveryoneOption() {
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: Text('Hapus pesan'),
|
|
content: Text('Bagaimana Anda ingin menghapus pesan ini?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_deleteSelectedMessages(forEveryone: false);
|
|
},
|
|
child: Text('Hapus untuk saya'),
|
|
),
|
|
TextButton(
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_deleteSelectedMessages(forEveryone: true);
|
|
},
|
|
child: Text('Hapus untuk semua orang'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteSelectedMessages({required bool forEveryone}) async {
|
|
final selectedIds = Set<String>.from(_selectedMessageIds);
|
|
final selectedMessages =
|
|
_messages.where((m) => selectedIds.contains(m.id)).toList();
|
|
|
|
// First remove from UI for immediate feedback
|
|
setState(() {
|
|
_messages.removeWhere((m) => selectedIds.contains(m.id));
|
|
_deletedMessageIds.addAll(selectedIds);
|
|
_selectedMessageIds.clear();
|
|
_isSelectMode = false;
|
|
});
|
|
|
|
// Save to local storage
|
|
_saveDeletedMessageIds();
|
|
|
|
// Show feedback
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('${selectedIds.length} pesan dihapus'),
|
|
backgroundColor: Colors.green,
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
|
|
// If deleting for everyone, call API for each message
|
|
if (forEveryone) {
|
|
int successCount = 0;
|
|
int failCount = 0;
|
|
|
|
for (final message in selectedMessages) {
|
|
try {
|
|
await _messageService.deleteMessage(message);
|
|
successCount++;
|
|
} catch (e) {
|
|
print('[ERROR] Failed to delete message for everyone: $e');
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
if (failCount > 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Gagal menghapus $failCount pesan dari server'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|