1472 lines
46 KiB
Dart
1472 lines
46 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:onufitness/services/logger_service.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:agora_chat_sdk/agora_chat_sdk.dart';
|
|
import 'package:onufitness/constants/api_enum_constant.dart';
|
|
import 'package:onufitness/constants/asset_constants.dart';
|
|
import 'package:onufitness/constants/color_constant.dart';
|
|
import 'package:onufitness/constants/text_constant.dart';
|
|
import 'package:onufitness/screens/chat/controllers/chat_controller.dart';
|
|
import 'package:onufitness/screens/chat/widgets/chat_input_area.dart';
|
|
import 'package:onufitness/screens/chat/widgets/voice/voice_message_buble_widget.dart';
|
|
import 'package:onufitness/screens/echoboard/widget/image_zoom.dart';
|
|
import 'package:onufitness/screens/chat/widgets/chat_appbar.dart';
|
|
import 'package:onufitness/screens/chat/widgets/chat_video_widgets.dart';
|
|
import 'package:onufitness/services/agora/agora_chat_service.dart';
|
|
import 'package:onufitness/utils/custom_sneakbar.dart';
|
|
|
|
class ChatDetailScreen extends StatefulWidget {
|
|
final String targetUserId;
|
|
final String targetUserName;
|
|
final String? targetUserAvatar;
|
|
final bool isGroupMessage;
|
|
const ChatDetailScreen({
|
|
super.key,
|
|
required this.targetUserId,
|
|
required this.targetUserName,
|
|
this.targetUserAvatar,
|
|
this.isGroupMessage = false,
|
|
});
|
|
|
|
@override
|
|
State<ChatDetailScreen> createState() => _ChatDetailScreenState();
|
|
}
|
|
|
|
class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
|
late ChatController chatCtrl;
|
|
late TextEditingController textController;
|
|
late ScrollController scrollController;
|
|
AgoraChatService agoraService = Get.put(AgoraChatService());
|
|
final logger = LoggerService();
|
|
|
|
// Track if we should maintain scroll position during pagination
|
|
bool maintainScrollPosition = false;
|
|
double scrollPosition = 0.0;
|
|
|
|
@override
|
|
void initState() {
|
|
if (!Get.isRegistered<ChatController>()) {
|
|
Get.put(ChatController());
|
|
}
|
|
chatCtrl = Get.find<ChatController>();
|
|
super.initState();
|
|
textController = TextEditingController();
|
|
scrollController = ScrollController();
|
|
chatCtrl.isGroupChattt.value = widget.isGroupMessage;
|
|
|
|
// Load existing messages for this conversation
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
// chatCtrl.isGroupChattt.value = widget.isGroupMessage;
|
|
initAgoraChatService();
|
|
isMyFriendCheck();
|
|
loadMessages();
|
|
// Set up listeners
|
|
setupControllers();
|
|
subscribeToUserPresence();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
textController.dispose();
|
|
scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
initAgoraChatService() async {
|
|
if (agoraService.currentUserId == null) {
|
|
await Future.delayed(Duration(milliseconds: 500));
|
|
|
|
// Check again
|
|
if (agoraService.currentUserId == null) {
|
|
bool success = await agoraService.loginToAgora();
|
|
if (!success) {
|
|
customSnackbar(
|
|
title: "Error",
|
|
message: "Failed to connect to chat",
|
|
duration: 2,
|
|
);
|
|
Get.back();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
subscribeToUserPresence() async {
|
|
await chatCtrl.subscribeToUserPresence(widget.targetUserId);
|
|
}
|
|
|
|
isMyFriendCheck() async {
|
|
await chatCtrl.friendCheck(connectedAgoraUserID: widget.targetUserId);
|
|
}
|
|
|
|
Future<void> loadMessages() async {
|
|
// Load messages from the conversation
|
|
await chatCtrl.loadMessages(widget.targetUserId);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_scrollToBottom();
|
|
});
|
|
}
|
|
|
|
void setupControllers() {
|
|
// For tracking scroll direction
|
|
scrollController.addListener(() {
|
|
final position = scrollController.position;
|
|
|
|
// Detect upward scroll near top to trigger pagination
|
|
if (position.userScrollDirection == ScrollDirection.forward &&
|
|
position.pixels <= 100) {
|
|
_loadMoreMessages();
|
|
}
|
|
});
|
|
|
|
// ONLY scroll to bottom when a new message is added at the end
|
|
ever(chatCtrl.messagesMap, (map) {
|
|
final messages = map[widget.targetUserId] ?? [];
|
|
|
|
// Only scroll to bottom if this is a NEW message appended
|
|
if (messages.isNotEmpty) {
|
|
final lastMsg = messages.last;
|
|
|
|
final isNewMessage =
|
|
DateTime.fromMillisecondsSinceEpoch(
|
|
lastMsg.serverTime,
|
|
).difference(DateTime.now()).inSeconds.abs() <
|
|
5;
|
|
|
|
if (isNewMessage) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_scrollToBottom();
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _loadMoreMessages() async {
|
|
final hasMore = chatCtrl.hasMoreMessages[widget.targetUserId] ?? false;
|
|
final isLoading = chatCtrl.isLoadingMore[widget.targetUserId] ?? false;
|
|
|
|
if (hasMore && !isLoading) {
|
|
// Save current scroll position
|
|
scrollPosition = scrollController.position.pixels;
|
|
// maintainScrollPosition = true;
|
|
|
|
await chatCtrl.loadMoreMessages(widget.targetUserId);
|
|
}
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
if (scrollController.hasClients) {
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
if (scrollController.hasClients &&
|
|
scrollController.position.maxScrollExtent > 0) {
|
|
scrollController.animateTo(
|
|
scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (!didPop) {
|
|
Get.back();
|
|
await chatCtrl.markAllMessagesAsRead(widget.targetUserId);
|
|
chatCtrl.selectedUserId.value = "";
|
|
|
|
await chatCtrl.unsuscribePresence(widget.targetUserId);
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
resizeToAvoidBottomInset: true,
|
|
|
|
body: Container(
|
|
decoration: BoxDecoration(
|
|
color: Color(primaryColor),
|
|
image: DecorationImage(
|
|
image: AssetImage(AssetConstants.chatAppBarBackgroundImage),
|
|
fit: BoxFit.cover,
|
|
opacity: 0.5,
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.max, // For Samsung phones
|
|
children: [
|
|
// Reactive app bar
|
|
Obx(() {
|
|
final status =
|
|
chatCtrl.presenceMap[widget.targetUserId] ?? '';
|
|
final isOnline = status == 'online';
|
|
|
|
return chatAppBar(
|
|
context: context,
|
|
chatCtrl: chatCtrl,
|
|
receiverName: widget.targetUserName,
|
|
showOnlineDot: isOnline,
|
|
targetUserAvatarUrl: widget.targetUserAvatar,
|
|
userId: widget.targetUserId,
|
|
isGroupChat: widget.isGroupMessage,
|
|
);
|
|
}),
|
|
|
|
// Chat messages
|
|
Expanded(
|
|
child: ChatMessageList(
|
|
chatCtrl: chatCtrl,
|
|
scrollController: scrollController,
|
|
targetUserId: widget.targetUserId,
|
|
isGroupMessage: widget.isGroupMessage,
|
|
),
|
|
),
|
|
|
|
// File upload loader
|
|
FileUploadLoader(
|
|
targetUserId: widget.targetUserId,
|
|
chatCtrl: chatCtrl,
|
|
),
|
|
|
|
// Conditionally show chat input
|
|
if (widget.isGroupMessage == true)
|
|
Column(
|
|
children: [
|
|
ChatInputAreaWithVoice(
|
|
chatCtrl: chatCtrl,
|
|
textController: textController,
|
|
targetUserId: widget.targetUserId,
|
|
isGroupMessage: widget.isGroupMessage,
|
|
targetUserName: widget.targetUserName,
|
|
),
|
|
Container(color: Colors.white, height: 20.h),
|
|
],
|
|
)
|
|
else
|
|
Obx(() {
|
|
if (chatCtrl.isMyFriendCheckLoading.value) {
|
|
// Using this Insted of Loader ( for better UX)
|
|
return Column(
|
|
children: [
|
|
ChatInputAreaWithVoice(
|
|
chatCtrl: chatCtrl,
|
|
textController: textController,
|
|
targetUserId: widget.targetUserId,
|
|
isGroupMessage: widget.isGroupMessage,
|
|
targetUserName: widget.targetUserName,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
if (chatCtrl.isMyFriend.value) {
|
|
return Column(
|
|
children: [
|
|
ChatInputAreaWithVoice(
|
|
chatCtrl: chatCtrl,
|
|
textController: textController,
|
|
targetUserId: widget.targetUserId,
|
|
isGroupMessage: widget.isGroupMessage,
|
|
targetUserName: widget.targetUserName,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: EdgeInsets.only(bottom: 20.sp, top: 15.sp),
|
|
child: Text(
|
|
"You cannot send messages to this user right now.",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.black),
|
|
),
|
|
),
|
|
Container(
|
|
height: MediaQuery.of(context).padding.bottom,
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Message List Component with Pagination Support
|
|
class ChatMessageList extends StatelessWidget {
|
|
final ChatController chatCtrl;
|
|
final ScrollController scrollController;
|
|
final String targetUserId;
|
|
final bool isGroupMessage;
|
|
const ChatMessageList({
|
|
super.key,
|
|
required this.chatCtrl,
|
|
required this.scrollController,
|
|
required this.targetUserId,
|
|
this.isGroupMessage = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
color: Colors.white,
|
|
child: Column(
|
|
children: [
|
|
// End-to-End Encryption Notice
|
|
const EncryptionNotice(),
|
|
// Message List
|
|
Expanded(child: Obx(() => messageListContent())),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget messageListContent() {
|
|
final messages = chatCtrl.messagesMap[targetUserId] ?? [];
|
|
final isLoadingMore = chatCtrl.isLoadingMore[targetUserId] ?? false;
|
|
final hasMoreMessages = chatCtrl.hasMoreMessages[targetUserId] ?? false;
|
|
|
|
if (messages.isEmpty && !isLoadingMore) {
|
|
return const EmptyChat();
|
|
}
|
|
|
|
return ListView.builder(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 20),
|
|
itemCount: messages.length + (isLoadingMore ? 1 : 0),
|
|
itemBuilder: (_, i) {
|
|
// Show loading indicator at the top
|
|
if (i == 0 && isLoadingMore) {
|
|
return const LoadingIndicator();
|
|
}
|
|
|
|
final messageIndex = isLoadingMore ? i - 1 : i;
|
|
final msg = messages[messageIndex];
|
|
|
|
final showDate =
|
|
messageIndex == 0 ||
|
|
!_isSameDay(
|
|
DateTime.fromMillisecondsSinceEpoch(
|
|
messages[messageIndex - 1].serverTime,
|
|
),
|
|
DateTime.fromMillisecondsSinceEpoch(msg.serverTime),
|
|
);
|
|
|
|
final isGroupMsg = isGroupMessage;
|
|
final isMe = msg.from == ChatClient.getInstance.currentUserId;
|
|
|
|
// Get sender name for group messages
|
|
String? senderName;
|
|
if (isGroupMsg && !isMe) {
|
|
// First try to get from userInfoMap
|
|
final userInfo = chatCtrl.userInfoMap[msg.from];
|
|
if (userInfo != null && userInfo.nickName?.isNotEmpty == true) {
|
|
senderName = userInfo.nickName;
|
|
} else if (userInfo != null && userInfo.userId.isNotEmpty == true) {
|
|
senderName = userInfo.userId;
|
|
} else {
|
|
// Fallback to sender's user ID if no display name is available
|
|
// senderName = msg.from ?? 'Unknown';
|
|
senderName = 'Unknown';
|
|
|
|
// Trigger fetch of user info for future messages
|
|
if (msg.from != null) {
|
|
chatCtrl.fetchUserInfo(msg.from!);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
if (showDate)
|
|
_DateSeparator(
|
|
date: DateTime.fromMillisecondsSinceEpoch(msg.serverTime),
|
|
),
|
|
CustomMessageBubble(
|
|
message: msg,
|
|
isMe: isMe,
|
|
isGroupMessage: isGroupMsg,
|
|
senderName: senderName,
|
|
chatController: chatCtrl,
|
|
),
|
|
|
|
// Show "Load All Messages" button at the top (after loading indicator)
|
|
if (messageIndex == 0 && !isLoadingMore && hasMoreMessages)
|
|
LoadAllMessagesButton(
|
|
chatCtrl: chatCtrl,
|
|
targetUserId: targetUserId,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
bool _isSameDay(DateTime date1, DateTime date2) {
|
|
return date1.year == date2.year &&
|
|
date1.month == date2.month &&
|
|
date1.day == date2.day;
|
|
}
|
|
}
|
|
|
|
// End-to-End Encryption Notice Widget
|
|
class EncryptionNotice extends StatelessWidget {
|
|
const EncryptionNotice({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(color: Colors.white),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.lock, size: regularSizeText, color: lightGreyColor),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
"End to End Encrypted",
|
|
style: TextStyle(
|
|
fontSize: smallSizeText,
|
|
color: lightGreyColor,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Loading Indicator Widget
|
|
class LoadingIndicator extends StatelessWidget {
|
|
const LoadingIndicator({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: const Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
"Loading messages...",
|
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Load All Messages Button Widget
|
|
class LoadAllMessagesButton extends StatelessWidget {
|
|
final ChatController chatCtrl;
|
|
final String targetUserId;
|
|
|
|
const LoadAllMessagesButton({
|
|
super.key,
|
|
required this.chatCtrl,
|
|
required this.targetUserId,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: ElevatedButton.icon(
|
|
onPressed: () async {
|
|
await chatCtrl.loadAllMessages(targetUserId);
|
|
},
|
|
icon: const Icon(Icons.history, size: 16),
|
|
label: const Text("Load All Messages"),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.blue.shade50,
|
|
foregroundColor: Colors.blue.shade700,
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class EmptyChat extends StatelessWidget {
|
|
const EmptyChat({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.chat_bubble_outline,
|
|
size: 80,
|
|
color: Colors.grey.withValues(alpha: 0.5),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
"No messages yet",
|
|
style: TextStyle(color: Colors.grey, fontSize: 16),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
"Say hello to start the conversation!",
|
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DateSeparator extends StatelessWidget {
|
|
final DateTime date;
|
|
|
|
const _DateSeparator({required this.date});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Divider(color: Colors.grey.shade300)),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade200,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
_formatMessageDate(date),
|
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
Expanded(child: Divider(color: Colors.grey.shade300)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatMessageDate(DateTime date) {
|
|
final now = DateTime.now();
|
|
if (_isSameDay(date, now)) {
|
|
return "Today";
|
|
} else if (_isSameDay(date, now.subtract(const Duration(days: 1)))) {
|
|
return "Yesterday";
|
|
} else if (now.difference(date).inDays < 7) {
|
|
return DateFormat('EEEE').format(date);
|
|
} else {
|
|
return DateFormat('MMM d, yyyy').format(date);
|
|
}
|
|
}
|
|
|
|
bool _isSameDay(DateTime date1, DateTime date2) {
|
|
return date1.year == date2.year &&
|
|
date1.month == date2.month &&
|
|
date1.day == date2.day;
|
|
}
|
|
}
|
|
|
|
class CustomMessageBubble extends StatelessWidget {
|
|
final ChatMessage message;
|
|
final bool isMe;
|
|
final bool isGroupMessage;
|
|
final String? senderName;
|
|
final ChatController chatController;
|
|
const CustomMessageBubble({
|
|
super.key,
|
|
required this.message,
|
|
required this.isMe,
|
|
required this.chatController,
|
|
this.isGroupMessage = false,
|
|
this.senderName,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final timeString = DateFormat(
|
|
'h:mm a',
|
|
).format(DateTime.fromMillisecondsSinceEpoch(message.serverTime));
|
|
|
|
return Align(
|
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
constraints: BoxConstraints(maxWidth: Get.width * 0.75),
|
|
margin: EdgeInsets.only(
|
|
top: 4.h,
|
|
bottom: 6.h,
|
|
left: isMe ? 30.w : 15.w,
|
|
right: isMe ? 15.w : 30.w,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: isMe ? Color(chatBubbleColor) : Colors.grey.shade200,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(isMe ? 20 : 2),
|
|
topRight: Radius.circular(isMe ? 2 : 20),
|
|
bottomLeft: const Radius.circular(20),
|
|
bottomRight: const Radius.circular(20),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.08),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
border:
|
|
isMe
|
|
? null
|
|
: Border.all(
|
|
color: Colors.grey.withValues(alpha: 0.1),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: getPaddingHorizontal(),
|
|
vertical: getPaddingVertical(),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Sender name for group messages
|
|
if (isGroupMessage && !isMe && senderName != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Text(
|
|
senderName!,
|
|
style: TextStyle(
|
|
fontSize: verySmallSizeText,
|
|
color: deeperShadeOfprimaryColor,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Message content based on type
|
|
messageContentWidget(chatController),
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
// Time and status row
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
timeString,
|
|
style: TextStyle(
|
|
fontSize: verySmallSizeText,
|
|
color: isMe ? Colors.grey : Color(darkGreyColor),
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
if (isMe && !isGroupMessage) ...[
|
|
const SizedBox(width: 4),
|
|
messageStatusIconWidget(),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
double getPaddingHorizontal() {
|
|
switch (message.body.type) {
|
|
case MessageType.IMAGE:
|
|
return 8.w;
|
|
case MessageType.FILE:
|
|
return 10.w;
|
|
default:
|
|
return 15.w;
|
|
}
|
|
}
|
|
|
|
double getPaddingVertical() {
|
|
switch (message.body.type) {
|
|
case MessageType.IMAGE:
|
|
return 8.h;
|
|
case MessageType.FILE:
|
|
return 10.w;
|
|
default:
|
|
return 5.h;
|
|
}
|
|
}
|
|
|
|
Widget messageContentWidget(ChatController chatController) {
|
|
final messageType = message.body.type;
|
|
|
|
// Handle text messages
|
|
if (messageType == MessageType.TXT) {
|
|
return textMessageWidget();
|
|
}
|
|
|
|
// Handle voice messages
|
|
if (messageType == MessageType.VOICE) {
|
|
return VoiceMessageBubble(
|
|
message: message,
|
|
isMe: isMe,
|
|
chatController: chatController,
|
|
);
|
|
}
|
|
|
|
// Handle file-based messages
|
|
if (message.body is! ChatFileMessageBody) {
|
|
return unsupportedMessageWidget();
|
|
}
|
|
|
|
final body = message.body as ChatFileMessageBody;
|
|
final fileName = body.displayName?.toLowerCase().trim() ?? '';
|
|
final extension = chatController.getFileExtension(fileName);
|
|
|
|
if (chatController.isImageExtension(extension)) {
|
|
return imageMessageWidget();
|
|
} else if (chatController.isVideoExtension(extension)) {
|
|
return videoMessageWidget();
|
|
} else if (chatController.isDocumentExtension(extension)) {
|
|
return fileMessageWidget();
|
|
} else {
|
|
return unsupportedMessageWidget();
|
|
}
|
|
}
|
|
|
|
Widget textMessageWidget() {
|
|
final textBody = message.body as ChatTextMessageBody;
|
|
return Text(
|
|
textBody.content,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: isMe ? Colors.black : Colors.black,
|
|
height: 1.3,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget imageMessageWidget() {
|
|
final imageBody = message.body as ChatFileMessageBody;
|
|
final chatCtrl = Get.find<ChatController>();
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: Get.width * 0.6,
|
|
maxHeight: 300,
|
|
minWidth: 150,
|
|
minHeight: 150,
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Stack(
|
|
children: [
|
|
customImageContainer(imageBody),
|
|
Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: Obx(() {
|
|
final isDownloading =
|
|
chatCtrl.isDownloading[message.msgId] ?? false;
|
|
final progress =
|
|
chatCtrl.downloadProgress[message.msgId] ?? 0.0;
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(30),
|
|
),
|
|
child:
|
|
isDownloading
|
|
? Container(
|
|
padding: const EdgeInsets.all(8),
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
value: progress,
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
)
|
|
: IconButton(
|
|
onPressed: () => chatCtrl.downloadFile(message),
|
|
icon: const Icon(
|
|
Icons.download,
|
|
color: Colors.white,
|
|
size: 18,
|
|
),
|
|
padding: const EdgeInsets.all(4),
|
|
constraints: const BoxConstraints(
|
|
minWidth: 32,
|
|
minHeight: 32,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget customImageContainer(ChatFileMessageBody imageBody) {
|
|
if (imageBody.localPath.isNotEmpty == true) {
|
|
final localFile = File(imageBody.localPath);
|
|
if (localFile.existsSync()) {
|
|
return GestureDetector(
|
|
onTap:
|
|
() => Get.to(
|
|
() => ImageZoomWidget(
|
|
imageUrl: imageBody.remotePath!,
|
|
heroTag: "Hey_hero_${DateTime.now().millisecondsSinceEpoch}",
|
|
),
|
|
),
|
|
child: Image.file(
|
|
localFile,
|
|
fit: BoxFit.cover,
|
|
errorBuilder:
|
|
(context, error, stackTrace) =>
|
|
defaultImagePlaceholder(imageBody),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Try to display remote image
|
|
if (imageBody.remotePath?.isNotEmpty == true) {
|
|
return GestureDetector(
|
|
onTap:
|
|
() => Get.to(
|
|
() => ImageZoomWidget(
|
|
imageUrl: imageBody.remotePath!,
|
|
heroTag: "${DateTime.now().millisecondsSinceEpoch}",
|
|
),
|
|
),
|
|
child: Image.network(
|
|
imageBody.remotePath!,
|
|
fit: BoxFit.cover,
|
|
loadingBuilder: (context, child, loadingProgress) {
|
|
if (loadingProgress == null) return child;
|
|
return Center(
|
|
child: CircularProgressIndicator(
|
|
value:
|
|
loadingProgress.expectedTotalBytes != null
|
|
? loadingProgress.cumulativeBytesLoaded /
|
|
loadingProgress.expectedTotalBytes!
|
|
: null,
|
|
),
|
|
);
|
|
},
|
|
errorBuilder:
|
|
(context, error, stackTrace) =>
|
|
defaultImagePlaceholder(imageBody),
|
|
),
|
|
);
|
|
}
|
|
|
|
return defaultImagePlaceholder(imageBody);
|
|
}
|
|
|
|
Widget defaultImagePlaceholder(ChatFileMessageBody imageBody) {
|
|
return Container(
|
|
width: double.infinity,
|
|
height: 150,
|
|
color: Colors.grey.shade300,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.image, size: 40, color: Colors.grey.shade600),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
imageBody.displayName ?? 'Image',
|
|
style: TextStyle(color: Colors.grey.shade600),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget videoMessageWidget() {
|
|
final videoBody = message.body as ChatFileMessageBody;
|
|
final chatCtrl = Get.find<ChatController>();
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: Get.width * 0.6,
|
|
maxHeight: 150,
|
|
minWidth: 150,
|
|
minHeight: 150,
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Stack(
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
height: 150.h,
|
|
color: Colors.black,
|
|
),
|
|
|
|
// Play button overlay
|
|
Positioned(
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
left: 0,
|
|
child: Container(
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
child: Center(
|
|
child: GestureDetector(
|
|
onTap: () => playVideo(videoBody),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.play_arrow,
|
|
color: Colors.white,
|
|
size: 30,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Download overlay if needed
|
|
Obx(() {
|
|
final isDownloading =
|
|
chatCtrl.isDownloading[message.msgId] ?? false;
|
|
final progress =
|
|
chatCtrl.downloadProgress[message.msgId] ?? 0.0;
|
|
|
|
if (isDownloading) {
|
|
return Container(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
value: progress,
|
|
color: Colors.white,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${(progress * 100).toInt()}%',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
videoBody.displayName ?? 'Video',
|
|
style: TextStyle(
|
|
fontSize: verySmallSizeText,
|
|
color: isMe ? Colors.black : Colors.black,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
|
|
// Download button beside video file name
|
|
Obx(() {
|
|
final isDownloading =
|
|
chatCtrl.isDownloading[message.msgId] ?? false;
|
|
|
|
return GestureDetector(
|
|
onTap:
|
|
isDownloading ? null : () => chatCtrl.downloadFile(message),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isDownloading ? Colors.grey : Colors.blue,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
isDownloading ? Icons.downloading : Icons.download,
|
|
size: regularSizeText,
|
|
color: Colors.white,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void playVideo(ChatFileMessageBody videoBody) async {
|
|
String? videoPath;
|
|
|
|
// Check if video is available locally
|
|
if (videoBody.localPath.isNotEmpty == true &&
|
|
File(videoBody.localPath).existsSync()) {
|
|
videoPath = videoBody.localPath;
|
|
} else if (videoBody.remotePath?.isNotEmpty == true) {
|
|
videoPath = videoBody.remotePath!;
|
|
}
|
|
|
|
if (videoPath != null) {
|
|
Get.to(() => VideoPlayerScreen(videoPath: videoPath!));
|
|
} else {
|
|
// Show download dialog
|
|
Get.dialog(
|
|
AlertDialog(
|
|
title: const Text('Video Not Available'),
|
|
content: const Text('Please download the video first to play it.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Get.back(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Get.back();
|
|
final chatCtrl = Get.find<ChatController>();
|
|
chatCtrl.downloadFile(message);
|
|
},
|
|
child: const Text('Download'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Updated fileMessageWidget method for PDF and other file handling
|
|
Widget fileMessageWidget() {
|
|
final fileBody = message.body as ChatFileMessageBody;
|
|
final chatCtrl = Get.find<ChatController>();
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
isMe ? Colors.white.withValues(alpha: 0.2) : Colors.grey.shade100,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: getFileIconColor(fileBody.displayName ?? ''),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Icon(
|
|
getFileIcon(fileBody.displayName ?? ''),
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
fileBody.displayName ?? 'Unknown File',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: isMe ? Colors.black87 : Colors.black,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
_formatFileSize(fileBody.fileSize!),
|
|
style: TextStyle(
|
|
fontSize: verySmallSizeText,
|
|
color: isMe ? Colors.black : Colors.black,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Download button only
|
|
Obx(() {
|
|
final isDownloading =
|
|
chatCtrl.isDownloading[message.msgId] ?? false;
|
|
final progress =
|
|
chatCtrl.downloadProgress[message.msgId] ?? 0.0;
|
|
|
|
if (isDownloading) {
|
|
return SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
value: progress,
|
|
strokeWidth: 2,
|
|
backgroundColor: Colors.grey.shade300,
|
|
),
|
|
Text(
|
|
'${(progress * 100).toInt()}%',
|
|
style: const TextStyle(fontSize: 8),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return GestureDetector(
|
|
onTap: () => chatCtrl.downloadFile(message),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Icon(Icons.download, color: Colors.white, size: 16),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// New VideoPlayerScreen widget
|
|
Widget unsupportedMessageWidget() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade100,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.orange.shade300),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.warning, color: Colors.orange.shade700),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Unsupported message type: ${message.body.type}',
|
|
style: TextStyle(color: Colors.orange.shade700, fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget messageStatusIconWidget() {
|
|
if (message.hasReadAck) {
|
|
return Icon(Icons.done_all, size: 14, color: Colors.blue);
|
|
} else if (message.hasDeliverAck) {
|
|
return Icon(Icons.done_all, size: 14, color: Colors.grey);
|
|
} else {
|
|
return Icon(Icons.check, size: 14, color: Colors.grey);
|
|
}
|
|
}
|
|
|
|
IconData getFileIcon(String fileName) {
|
|
final extension = fileName.split('.').last.toLowerCase();
|
|
switch (extension) {
|
|
case 'pdf':
|
|
return Icons.picture_as_pdf;
|
|
case 'doc':
|
|
case 'docx':
|
|
return Icons.description;
|
|
case 'xls':
|
|
case 'xlsx':
|
|
return Icons.table_chart;
|
|
case 'ppt':
|
|
case 'pptx':
|
|
return Icons.slideshow;
|
|
case 'zip':
|
|
case 'rar':
|
|
return Icons.archive;
|
|
case 'txt':
|
|
return Icons.text_snippet;
|
|
default:
|
|
return Icons.insert_drive_file;
|
|
}
|
|
}
|
|
|
|
Color getFileIconColor(String fileName) {
|
|
final extension = fileName.split('.').last.toLowerCase();
|
|
switch (extension) {
|
|
case 'pdf':
|
|
return Colors.red;
|
|
case 'doc':
|
|
case 'docx':
|
|
return Colors.blue;
|
|
case 'xls':
|
|
case 'xlsx':
|
|
return Colors.green;
|
|
case 'ppt':
|
|
case 'pptx':
|
|
return Colors.orange;
|
|
case 'zip':
|
|
case 'rar':
|
|
return Colors.purple;
|
|
case 'txt':
|
|
return Colors.grey;
|
|
default:
|
|
return Colors.blueGrey;
|
|
}
|
|
}
|
|
|
|
String _formatFileSize(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
if (bytes < 1024 * 1024 * 1024) {
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
}
|
|
}
|
|
|
|
class SendButton extends StatelessWidget {
|
|
final ChatController chatCtrl;
|
|
final TextEditingController textController;
|
|
final String targetUserId;
|
|
final bool isGroupMessage;
|
|
final String targetUserName;
|
|
|
|
const SendButton({
|
|
super.key,
|
|
required this.chatCtrl,
|
|
required this.textController,
|
|
required this.targetUserId,
|
|
this.isGroupMessage = false,
|
|
required this.targetUserName,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: textController,
|
|
builder: (context, value, child) {
|
|
return GestureDetector(
|
|
onTap: () {
|
|
_sendMessage();
|
|
},
|
|
onLongPress: () {},
|
|
child: Container(
|
|
width: 45.w,
|
|
height: 45.h,
|
|
decoration: BoxDecoration(
|
|
color: const Color(primaryColor),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(Icons.send, color: Colors.black),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _sendMessage() {
|
|
final text = textController.text.trim();
|
|
if (text.isNotEmpty) {
|
|
chatCtrl.sendTextMessage(targetUserId, text, isGroup: isGroupMessage);
|
|
textController.clear();
|
|
|
|
if (isGroupMessage == true) {
|
|
chatCtrl.sendGroupChatNotification(
|
|
notificationMessage: text,
|
|
notificationTitle: "",
|
|
notificationType: ApiEnum.ChatNotification,
|
|
tribeId: targetUserId,
|
|
);
|
|
}
|
|
|
|
if (!isGroupMessage) {
|
|
chatCtrl.sendChatNotification(
|
|
notificationMessage: text,
|
|
notificationTitle: "",
|
|
notificationType: ApiEnum.ChatNotification,
|
|
receiverId: targetUserId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class FileUploadLoader extends StatelessWidget {
|
|
final String targetUserId;
|
|
final ChatController chatCtrl;
|
|
|
|
const FileUploadLoader({
|
|
super.key,
|
|
required this.targetUserId,
|
|
required this.chatCtrl,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Obx(() {
|
|
final isUploading = chatCtrl.isSending[targetUserId] ?? false;
|
|
|
|
return AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 250),
|
|
switchInCurve: Curves.easeOut,
|
|
switchOutCurve: Curves.easeIn,
|
|
transitionBuilder: (child, animation) {
|
|
return SizeTransition(sizeFactor: animation, child: child);
|
|
},
|
|
child:
|
|
isUploading
|
|
? Container(
|
|
key: const ValueKey("file_uploading"),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(primaryColor).withValues(alpha: 0.1),
|
|
border: Border(
|
|
top: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
offset: const Offset(0, -2),
|
|
blurRadius: 6,
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(6),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 24.w,
|
|
height: 24.h,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5,
|
|
color: const Color(primaryColor),
|
|
backgroundColor: const Color(
|
|
primaryColor,
|
|
).withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
"Sending file...",
|
|
style: TextStyle(
|
|
color: const Color(primaryColor),
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
// Tooltip(
|
|
// message: "Cancel upload",
|
|
// child: IconButton(
|
|
// onPressed: () {
|
|
// chatCtrl.isSending[targetUserId] = false;
|
|
// },
|
|
// icon: const Icon(Icons.close, size: 20),
|
|
// color: Colors.grey.shade600,
|
|
// constraints: const BoxConstraints(
|
|
// minWidth: 32,
|
|
// minHeight: 32,
|
|
// ),
|
|
// ),
|
|
// ),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
);
|
|
});
|
|
}
|
|
}
|