1005 lines
37 KiB
Dart
1005 lines
37 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import 'package:flutter_riverpod/legacy.dart';
|
||
import 'package:get/get.dart';
|
||
|
||
import 'package:taxglide/consts/app_asstes.dart';
|
||
import 'package:taxglide/consts/notification_webscoket.dart';
|
||
import 'package:taxglide/controller/api_contoller.dart';
|
||
|
||
import 'package:taxglide/model/chat_model.dart';
|
||
import 'package:taxglide/view/Mahi_chat/chat_profile_screen.dart';
|
||
import 'package:taxglide/view/Mahi_chat/comman_input_button.dart';
|
||
import 'package:taxglide/view/Mahi_chat/file_images_screen.dart';
|
||
|
||
import 'package:taxglide/view/Mahi_chat/webscoket.dart';
|
||
|
||
// ⭐ Provider to hold the tagged message
|
||
final taggedMessageProvider = StateProvider<MessageModel?>((ref) => null);
|
||
|
||
// ⭐ Global key map to track message positions (if you want from outside)
|
||
final messageKeysProvider = StateProvider<Map<int, GlobalKey>>((ref) => {});
|
||
|
||
class LiveChatScreen extends ConsumerStatefulWidget {
|
||
final int chatid;
|
||
final String? fileid;
|
||
const LiveChatScreen({super.key, required this.chatid, required this.fileid});
|
||
|
||
@override
|
||
ConsumerState<LiveChatScreen> createState() => _LiveChatScreenState();
|
||
}
|
||
|
||
class _LiveChatScreenState extends ConsumerState<LiveChatScreen>
|
||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||
@override
|
||
bool get wantKeepAlive => true;
|
||
|
||
final ScrollController scrollController = ScrollController();
|
||
final ChatWebSocketService _wsService = ChatWebSocketService();
|
||
|
||
bool showScrollButton = false;
|
||
bool userIsReading = false;
|
||
bool _isPaginationLoading = false;
|
||
bool _hasNewMessage = false;
|
||
bool _isOtherUserTyping = false;
|
||
Timer? _typingTimeoutTimer;
|
||
|
||
// For reply scroll - FIXED: Use unique key per message
|
||
final Map<String, GlobalKey> _messageKeys = {};
|
||
|
||
// ⭐⭐⭐ Key variables for smooth pagination
|
||
final double _paginationTriggerOffset = 300; // Start loading 300px before top
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
WidgetsBinding.instance.addObserver(this);
|
||
|
||
// ✅ Tell the global notification WS that we are viewing this chat,
|
||
// so it suppresses banner notifications for this specific chat.
|
||
NotificationWebSocket().setActiveChatId(widget.chatid.toString());
|
||
|
||
_initializeWebSocket();
|
||
|
||
scrollController.addListener(_handleScroll);
|
||
|
||
// Refresh immediately in background if already loaded
|
||
Future.microtask(() {
|
||
final currentState = ref.read(chatMessagesProvider(widget.chatid));
|
||
if (currentState is AsyncData) {
|
||
ref.read(chatMessagesProvider(widget.chatid).notifier).refresh();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ⭐ WhatsApp-like Smooth Pagination Logic
|
||
void _handleScroll() {
|
||
if (!scrollController.hasClients) return;
|
||
|
||
double position = scrollController.position.pixels;
|
||
double maxExtent = scrollController.position.maxScrollExtent;
|
||
|
||
// In a reversed list, maxExtent is the "top" (older messages)
|
||
if (position >= maxExtent - _paginationTriggerOffset &&
|
||
!_isPaginationLoading) {
|
||
_triggerPagination();
|
||
}
|
||
|
||
// Show scroll to bottom button
|
||
// In a reversed list, bottom is 0
|
||
if (position > 100) {
|
||
if (!showScrollButton) {
|
||
setState(() => showScrollButton = true);
|
||
}
|
||
} else {
|
||
if (showScrollButton) {
|
||
setState(() => showScrollButton = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⭐ Simplified pagination for reversed list
|
||
Future<void> _triggerPagination() async {
|
||
final notifier = ref.read(chatMessagesProvider(widget.chatid).notifier);
|
||
if (!notifier.hasNextPage || _isPaginationLoading) return;
|
||
|
||
setState(() => _isPaginationLoading = true);
|
||
|
||
try {
|
||
await notifier.loadMessages(); // Prepends older messages naturally
|
||
} catch (e) {
|
||
debugPrint("❌ Pagination error: $e");
|
||
} finally {
|
||
if (mounted) setState(() => _isPaginationLoading = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
super.didChangeAppLifecycleState(state);
|
||
|
||
debugPrint("📱 App Lifecycle State: $state");
|
||
|
||
if (state == AppLifecycleState.resumed) {
|
||
debugPrint("🔄 App resumed - checking for new messages");
|
||
|
||
if (_hasNewMessage) {
|
||
_silentRefresh();
|
||
_hasNewMessage = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _silentRefresh() async {
|
||
try {
|
||
debugPrint("🔄 Starting silent refresh...");
|
||
|
||
await ref.read(chatMessagesProvider(widget.chatid).notifier).refresh();
|
||
|
||
debugPrint("✅ Silent refresh completed");
|
||
|
||
// In a reversed list, new messages are at 0. No manual scroll needed.
|
||
// The reverse: true property handles this naturally.
|
||
if (mounted) {
|
||
setState(() {});
|
||
}
|
||
} catch (e) {
|
||
debugPrint("❌ Error during silent refresh: $e");
|
||
}
|
||
}
|
||
|
||
Future<void> _initializeWebSocket() async {
|
||
try {
|
||
_wsService.onMessageReceived = (Map<String, dynamic> message) async {
|
||
debugPrint("📨 WebSocket message received: $message");
|
||
|
||
try {
|
||
final event = message['event'] as String?;
|
||
|
||
if (event == 'message.sent' || event == 'message.received') {
|
||
debugPrint("🔔 New message detected!");
|
||
|
||
if (WidgetsBinding.instance.lifecycleState ==
|
||
AppLifecycleState.resumed) {
|
||
debugPrint("✅ Screen active - refreshing now");
|
||
await _silentRefresh();
|
||
} else {
|
||
debugPrint("⏳ Screen not active - will refresh on resume");
|
||
_hasNewMessage = true;
|
||
}
|
||
// ℹ️ Notification banner is handled centrally in NotificationWebSocket.
|
||
// No need to show it here – it is already suppressed for this chat.
|
||
}
|
||
|
||
if (event == 'client-typing') {
|
||
debugPrint("⌨️ Typing event received: ${message['data']}");
|
||
_handleTypingEvent(message['data']);
|
||
}
|
||
} catch (e) {
|
||
debugPrint("❌ Error handling WebSocket message: $e");
|
||
}
|
||
};
|
||
|
||
_wsService.onConnected = () {
|
||
debugPrint("✅ WebSocket Connected");
|
||
};
|
||
|
||
_wsService.onDisconnected = () {
|
||
debugPrint("🔌 WebSocket Disconnected");
|
||
};
|
||
|
||
_wsService.onError = (error) {
|
||
debugPrint("❌ WebSocket Error: $error");
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text("Connection error: $error"),
|
||
backgroundColor: Colors.red,
|
||
duration: const Duration(seconds: 2),
|
||
),
|
||
);
|
||
}
|
||
};
|
||
|
||
await _wsService.connect(chatId: widget.chatid.toString());
|
||
} catch (e) {
|
||
debugPrint("❌ Error initializing WebSocket: $e");
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
// ✅ Clear active chat so notifications resume when user leaves this screen.
|
||
NotificationWebSocket().clearActiveChatId();
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
_wsService.disconnect();
|
||
scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _handleTypingEvent(dynamic data) {
|
||
try {
|
||
Map<String, dynamic> dataMap;
|
||
if (data is String) {
|
||
dataMap = json.decode(data);
|
||
} else if (data is Map) {
|
||
dataMap = Map<String, dynamic>.from(data);
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
final bool isTyping = dataMap['is_typing'] == true;
|
||
|
||
setState(() {
|
||
_isOtherUserTyping = isTyping;
|
||
});
|
||
|
||
// Auto-clear typing status after 5 seconds of no update
|
||
_typingTimeoutTimer?.cancel();
|
||
if (_isOtherUserTyping) {
|
||
_typingTimeoutTimer = Timer(const Duration(seconds: 5), () {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isOtherUserTyping = false;
|
||
});
|
||
}
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint("❌ Error parsing typing data: $e");
|
||
}
|
||
}
|
||
|
||
void scrollToBottom({bool animated = false}) {
|
||
// In a reversed list, bottom is pixels: 0
|
||
if (!scrollController.hasClients) return;
|
||
|
||
if (animated) {
|
||
scrollController.animateTo(
|
||
0,
|
||
duration: const Duration(milliseconds: 400),
|
||
curve: Curves.easeOutCubic,
|
||
);
|
||
} else {
|
||
scrollController.jumpTo(0);
|
||
}
|
||
}
|
||
|
||
// ⭐ FIXED: Use stable key that doesn't change with pagination
|
||
bool scrollToMessage(String messageKey) {
|
||
final key = _messageKeys[messageKey];
|
||
|
||
if (key != null && key.currentContext != null) {
|
||
Scrollable.ensureVisible(
|
||
key.currentContext!,
|
||
duration: const Duration(milliseconds: 500),
|
||
curve: Curves.easeInOut,
|
||
alignment: 0.3,
|
||
);
|
||
|
||
// Trigger highlight animation
|
||
if (key is GlobalKey<SwipeableMessageBubbleState>) {
|
||
key.currentState?.highlight();
|
||
} else {
|
||
final state = key.currentContext!
|
||
.findAncestorStateOfType<SwipeableMessageBubbleState>();
|
||
state?.highlight();
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ⭐ NEW: Search and scroll to message that might not be loaded yet
|
||
Future<void> _scrollToMissingMessage(int tagId) async {
|
||
final notifier = ref.read(chatMessagesProvider(widget.chatid).notifier);
|
||
|
||
// Check if it exists in locally loaded messages first
|
||
final currentMessages =
|
||
ref.read(chatMessagesProvider(widget.chatid)).value ?? [];
|
||
final existingIndex = currentMessages.indexWhere((m) => m.id == tagId);
|
||
|
||
if (existingIndex != -1) {
|
||
final targetMsg = currentMessages[existingIndex];
|
||
final keyString = _getMessageKeyString(targetMsg);
|
||
|
||
// 1. Try direct scroll
|
||
if (scrollToMessage(keyString)) return;
|
||
|
||
// 2. If not rendered, perform a smooth animation to force it
|
||
final reversedIndex = (currentMessages.length - 1 - existingIndex);
|
||
final targetOffset = reversedIndex * 150.0; // Estimated position
|
||
|
||
await scrollController.animateTo(
|
||
targetOffset,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
scrollToMessage(keyString);
|
||
return;
|
||
}
|
||
|
||
// --- If NOT in memory, paginate silently (no full-screen loader) ---
|
||
try {
|
||
bool found = false;
|
||
int maxRetries = 20;
|
||
|
||
while (!found && maxRetries > 0) {
|
||
final messages =
|
||
ref.read(chatMessagesProvider(widget.chatid)).value ?? [];
|
||
final index = messages.indexWhere((m) => m.id == tagId);
|
||
|
||
if (index != -1) {
|
||
final targetMsg = messages[index];
|
||
final keyString = _getMessageKeyString(targetMsg);
|
||
|
||
final reversedIndex = (messages.length - 1 - index);
|
||
final targetOffset = reversedIndex * 150.0;
|
||
|
||
await scrollController.animateTo(
|
||
targetOffset,
|
||
duration: const Duration(milliseconds: 400),
|
||
curve: Curves.easeOut,
|
||
);
|
||
|
||
// Retry highlight after animation
|
||
for (int retry = 0; retry < 3; retry++) {
|
||
await Future.delayed(Duration(milliseconds: 100 + (retry * 100)));
|
||
if (scrollToMessage(keyString)) {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (found) break;
|
||
}
|
||
|
||
if (!notifier.hasNextPage) break;
|
||
await notifier.loadMessages();
|
||
maxRetries--;
|
||
}
|
||
|
||
if (!found && mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text("Could not find the original message")),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint("❌ Error searching for message: $e");
|
||
}
|
||
}
|
||
|
||
// ⭐ FIXED: Generate unique string key for each message
|
||
GlobalKey<SwipeableMessageBubbleState> _getKeyForMessage(String messageKey) {
|
||
if (!_messageKeys.containsKey(messageKey)) {
|
||
_messageKeys[messageKey] = GlobalKey<SwipeableMessageBubbleState>();
|
||
}
|
||
return _messageKeys[messageKey] as GlobalKey<SwipeableMessageBubbleState>;
|
||
}
|
||
|
||
// ⭐ FIXED: Generate stable key from message data
|
||
String _getMessageKeyString(MessageModel msg) {
|
||
// Unique features: ID, type, and creation time
|
||
final id = msg.id;
|
||
final createdAt = msg.createdAt ?? '';
|
||
final message =
|
||
msg.message.hashCode; // Add message hash for extra uniqueness
|
||
return 'msg_${id}_${createdAt}_$message';
|
||
}
|
||
|
||
String formatMessageDate(DateTime date) {
|
||
final now = DateTime.now();
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
final msgDay = DateTime(date.year, date.month, date.day);
|
||
|
||
if (msgDay == today) return "Today";
|
||
if (msgDay == today.subtract(const Duration(days: 1))) return "Yesterday";
|
||
return "${date.day}-${date.month}-${date.year}";
|
||
}
|
||
|
||
String formatTime(DateTime time) {
|
||
final hour = time.hour % 12 == 0 ? 12 : time.hour % 12;
|
||
final minute = time.minute.toString().padLeft(2, '0');
|
||
final period = time.hour >= 12 ? "PM" : "AM";
|
||
return "$hour:$minute $period";
|
||
}
|
||
|
||
Widget buildTick(int isDelivered, int isRead) {
|
||
if (isRead == 1) {
|
||
return const Icon(Icons.done_all, size: 16, color: Colors.blue);
|
||
} else if (isDelivered == 1) {
|
||
return const Icon(Icons.done_all, size: 16, color: Colors.grey);
|
||
} else {
|
||
return const Icon(Icons.check, size: 16, color: Colors.grey);
|
||
}
|
||
}
|
||
|
||
// 🖼️ NEW: Show Image Grid Dialog
|
||
void _showImageGridDialog(List<String> imageUrls, int initialIndex) {
|
||
showDialog(
|
||
context: context,
|
||
barrierColor: Colors.black87,
|
||
builder: (context) =>
|
||
ImageGridViewer(imageUrls: imageUrls, initialIndex: initialIndex),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
super.build(context);
|
||
|
||
final chatAsync = ref.watch(chatMessagesProvider(widget.chatid));
|
||
final taggedMessage = ref.watch(taggedMessageProvider);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(
|
||
(widget.fileid != null &&
|
||
widget.fileid.toString().isNotEmpty &&
|
||
widget.fileid.toString() != "0")
|
||
? 'File ID : ${widget.fileid.toString()}'
|
||
: "Live Chat",
|
||
style: const TextStyle(
|
||
fontFamily: "Gilroy-SemiBold",
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
backgroundColor: Colors.white,
|
||
elevation: 1,
|
||
foregroundColor: Colors.black,
|
||
centerTitle: true,
|
||
actions: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 12),
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
Get.to(ChatProfileScreen(chatid: widget.chatid));
|
||
},
|
||
child: Container(
|
||
width: 37.446807861328125,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFAFAFA),
|
||
borderRadius: BorderRadius.circular(6.81),
|
||
border: Border.all(
|
||
color: const Color(0xFF1E780C),
|
||
width: 0.85,
|
||
),
|
||
),
|
||
child: const Icon(Icons.person, color: Color(0xFF1E780C)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
body: Stack(
|
||
children: [
|
||
Container(
|
||
decoration: const BoxDecoration(
|
||
image: DecorationImage(
|
||
image: AssetImage(AppAssets.backgroundimages),
|
||
fit: BoxFit.cover,
|
||
),
|
||
),
|
||
),
|
||
Column(
|
||
children: [
|
||
Expanded(
|
||
child: chatAsync.when(
|
||
loading: () => const Center(child: SizedBox.shrink()),
|
||
error: (e, _) => Center(child: Text(e.toString())),
|
||
data: (messages) {
|
||
final seenKeys = <String>{};
|
||
final uniqueMessages = messages.where((msg) {
|
||
final key = _getMessageKeyString(msg);
|
||
if (seenKeys.contains(key)) return false;
|
||
seenKeys.add(key);
|
||
return true;
|
||
}).toList();
|
||
|
||
final reversedMessages = uniqueMessages.reversed.toList();
|
||
|
||
return Stack(
|
||
children: [
|
||
ListView.builder(
|
||
controller: scrollController,
|
||
reverse: true, // ⭐ Index 0 is now at the bottom
|
||
physics: const ClampingScrollPhysics(),
|
||
cacheExtent:
|
||
1000, // ⭐ Help with deep scrolling visibility
|
||
padding: const EdgeInsets.only(
|
||
left: 15,
|
||
right: 15,
|
||
top: 15,
|
||
bottom: 15,
|
||
),
|
||
itemCount: reversedMessages.length,
|
||
itemBuilder: (context, index) {
|
||
final msg = reversedMessages[index];
|
||
final msgDate = DateTime.parse(msg.createdAt!);
|
||
|
||
// In reversed list, header shows if previous message (index+1) has different date
|
||
final showDateHeader =
|
||
index == reversedMessages.length - 1 ||
|
||
formatMessageDate(
|
||
DateTime.parse(
|
||
reversedMessages[index + 1].createdAt!,
|
||
),
|
||
) !=
|
||
formatMessageDate(msgDate);
|
||
|
||
// ⭐ FIXED: Use stable key (no index dependency)
|
||
final messageKeyString = _getMessageKeyString(msg);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (showDateHeader)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 10,
|
||
),
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 5,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.06),
|
||
borderRadius: BorderRadius.circular(
|
||
12,
|
||
),
|
||
),
|
||
child: Text(
|
||
formatMessageDate(msgDate),
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SwipeableMessageBubble(
|
||
message: msg,
|
||
msgDate: msgDate,
|
||
formatTime: formatTime,
|
||
buildTick: buildTick,
|
||
onSwipe: () {
|
||
ref
|
||
.read(
|
||
taggedMessageProvider.notifier,
|
||
)
|
||
.state =
|
||
msg;
|
||
},
|
||
onParentTagTap: (tagId) {
|
||
if (tagId == null) return;
|
||
|
||
// ALWAYS call _scrollToMissingMessage now as it handles both
|
||
// local smooth scroll and remote paginate search correctly.
|
||
_scrollToMissingMessage(tagId);
|
||
},
|
||
// 🖼️ NEW: Pass image grid callback
|
||
onImageTap: (imageUrls, initialIndex) {
|
||
_showImageGridDialog(
|
||
imageUrls,
|
||
initialIndex,
|
||
);
|
||
},
|
||
key: _getKeyForMessage(messageKeyString),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
// ⭐⭐⭐ Subtle loading indicator at top
|
||
if (_isPaginationLoading)
|
||
Positioned(
|
||
top: 5,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.9),
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.1),
|
||
blurRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: const [
|
||
SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
),
|
||
SizedBox(width: 8),
|
||
Text(
|
||
"Loading...",
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
if (taggedMessage != null)
|
||
GestureDetector(
|
||
onTap: () {
|
||
if (taggedMessage != null) {
|
||
// ⭐ FIXED: Need to find the message in the list to get proper key
|
||
final chatAsync = ref.read(
|
||
chatMessagesProvider(widget.chatid),
|
||
);
|
||
chatAsync.whenData((messages) {
|
||
_scrollToMissingMessage(taggedMessage.id);
|
||
});
|
||
}
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 15,
|
||
vertical: 8,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[200],
|
||
border: Border(top: BorderSide(color: Colors.grey[300]!)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 4,
|
||
height: 50,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF630A73),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
taggedMessage.user?.name ?? "Unknown",
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 13,
|
||
color: Color(0xFF630A73),
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
if (taggedMessage.type == "file" &&
|
||
taggedMessage.fileName != null)
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.attach_file,
|
||
size: 14,
|
||
color: Colors.grey[600],
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
taggedMessage.fileName!,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: Colors.grey[700],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else
|
||
Text(
|
||
taggedMessage.message,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: Colors.grey[700],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 20),
|
||
padding: EdgeInsets.zero,
|
||
onPressed: () {
|
||
ref.read(taggedMessageProvider.notifier).state =
|
||
null;
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_isOtherUserTyping)
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 20, bottom: 5),
|
||
child: Row(
|
||
children: [
|
||
const SizedBox(
|
||
width: 12,
|
||
height: 12,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Color(0xFF1E780C),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
"Typing...",
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Colors.grey[600],
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
ChatInputBox(
|
||
widget.chatid,
|
||
onMessageSent: () {
|
||
setState(() {
|
||
userIsReading = false;
|
||
});
|
||
scrollToBottom(animated: true);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
if (showScrollButton)
|
||
Positioned(
|
||
bottom: 150,
|
||
right: 20,
|
||
child: FloatingActionButton(
|
||
heroTag: "scrollBtn",
|
||
backgroundColor: Colors.green,
|
||
mini: true,
|
||
onPressed: () {
|
||
scrollToBottom(animated: true);
|
||
},
|
||
child: const Icon(Icons.arrow_downward, color: Colors.white),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// 🖼️ NEW: Image Grid Viewer Widget
|
||
class ImageGridViewer extends StatefulWidget {
|
||
final List<String> imageUrls;
|
||
final int initialIndex;
|
||
|
||
const ImageGridViewer({
|
||
Key? key,
|
||
required this.imageUrls,
|
||
required this.initialIndex,
|
||
}) : super(key: key);
|
||
|
||
@override
|
||
State<ImageGridViewer> createState() => _ImageGridViewerState();
|
||
}
|
||
|
||
class _ImageGridViewerState extends State<ImageGridViewer> {
|
||
late PageController _pageController;
|
||
late int _currentIndex;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_currentIndex = widget.initialIndex;
|
||
_pageController = PageController(initialPage: widget.initialIndex);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_pageController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Dialog(
|
||
backgroundColor: Colors.transparent,
|
||
insetPadding: EdgeInsets.zero,
|
||
child: Container(
|
||
width: double.infinity,
|
||
height: double.infinity,
|
||
color: Colors.black87,
|
||
child: Stack(
|
||
children: [
|
||
// Main Image Viewer
|
||
PageView.builder(
|
||
controller: _pageController,
|
||
onPageChanged: (index) {
|
||
setState(() {
|
||
_currentIndex = index;
|
||
});
|
||
},
|
||
itemCount: widget.imageUrls.length,
|
||
itemBuilder: (context, index) {
|
||
return Center(
|
||
child: InteractiveViewer(
|
||
minScale: 0.5,
|
||
maxScale: 4.0,
|
||
child: Image.network(
|
||
widget.imageUrls[index],
|
||
fit: BoxFit.contain,
|
||
loadingBuilder: (context, child, loadingProgress) {
|
||
if (loadingProgress == null) return child;
|
||
return Center(
|
||
child: CircularProgressIndicator(
|
||
value: loadingProgress.expectedTotalBytes != null
|
||
? loadingProgress.cumulativeBytesLoaded /
|
||
loadingProgress.expectedTotalBytes!
|
||
: null,
|
||
color: Colors.white,
|
||
),
|
||
);
|
||
},
|
||
errorBuilder: (context, error, stackTrace) {
|
||
return const Center(
|
||
child: Icon(
|
||
Icons.broken_image,
|
||
color: Colors.white54,
|
||
size: 64,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
|
||
// Close Button
|
||
Positioned(
|
||
top: 40,
|
||
right: 20,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.close, color: Colors.white, size: 30),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
),
|
||
|
||
// Image Counter
|
||
Positioned(
|
||
top: 50,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 8,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Text(
|
||
'${_currentIndex + 1} / ${widget.imageUrls.length}',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Thumbnail Grid at Bottom
|
||
Positioned(
|
||
bottom: 20,
|
||
left: 0,
|
||
right: 0,
|
||
child: Container(
|
||
height: 80,
|
||
child: ListView.builder(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
itemCount: widget.imageUrls.length,
|
||
itemBuilder: (context, index) {
|
||
final isSelected = index == _currentIndex;
|
||
return GestureDetector(
|
||
onTap: () {
|
||
_pageController.animateToPage(
|
||
index,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
);
|
||
},
|
||
child: Container(
|
||
width: 112.8125,
|
||
height: 66.88169860839844,
|
||
margin: const EdgeInsets.only(right: 10),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(
|
||
color: isSelected
|
||
? Colors.white
|
||
: Colors.transparent,
|
||
width: 3,
|
||
),
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Image.network(
|
||
widget.imageUrls[index],
|
||
fit: BoxFit.cover,
|
||
loadingBuilder: (context, child, loadingProgress) {
|
||
if (loadingProgress == null) return child;
|
||
return Container(
|
||
color: Colors.grey[800],
|
||
child: const Center(
|
||
child: SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white54,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
errorBuilder: (context, error, stackTrace) {
|
||
return Container(
|
||
color: Colors.grey[800],
|
||
child: const Icon(
|
||
Icons.broken_image,
|
||
color: Colors.white54,
|
||
size: 24,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|