// ⭐⭐⭐ UPDATED SWIPEABLE MESSAGE BUBBLE WITH IMAGE GRID VIEWER ⭐⭐⭐ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:taxglide/consts/download_helper.dart'; import 'package:taxglide/model/chat_model.dart'; class SwipeableMessageBubble extends StatefulWidget { final MessageModel message; final DateTime msgDate; final String Function(DateTime) formatTime; final Widget Function(int, int) buildTick; final VoidCallback onSwipe; final Function(int?)? onParentTagTap; final Function(List imageUrls, int initialIndex)? onImageTap; // 🖼️ NEW const SwipeableMessageBubble({ super.key, required this.message, required this.msgDate, required this.formatTime, required this.buildTick, required this.onSwipe, this.onParentTagTap, this.onImageTap, // 🖼️ NEW }); @override State createState() => SwipeableMessageBubbleState(); } class SwipeableMessageBubbleState extends State with SingleTickerProviderStateMixin { double _dragExtent = 0; late AnimationController _controller; late Animation _animation; final Map _downloadingFiles = {}; final Map _downloadedFiles = {}; bool _isHighlighted = false; void highlight() { if (!mounted) return; // Reset first to ensure it re-triggers if already highlighted setState(() => _isHighlighted = false); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() => _isHighlighted = true); Future.delayed(const Duration(milliseconds: 1500), () { if (mounted) setState(() => _isHighlighted = false); }); }); } @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); _animation = Tween(begin: 0, end: 0).animate(_controller) ..addListener(() { setState(() { _dragExtent = _animation.value; }); }); _checkDownloadedFiles(); } Future _checkDownloadedFiles() async { for (var doc in widget.message.uploadedDocuments) { final existingPath = await DownloadHelper.checkFileExistsInReceived( doc.filePath, ); if (existingPath != null && mounted) { setState(() { _downloadedFiles[doc.fileName] = true; }); } } } @override void dispose() { _controller.dispose(); super.dispose(); } void _handleDragUpdate(DragUpdateDetails details) { if (!mounted) return; final isUserMessage = widget.message.chatBy == "user"; final delta = details.primaryDelta ?? 0; if ((isUserMessage && delta < 0) || (!isUserMessage && delta > 0)) { setState(() { _dragExtent += delta; _dragExtent = _dragExtent.clamp( isUserMessage ? -80.0 : 0.0, isUserMessage ? 0.0 : 80.0, ); }); } } void _handleDragEnd(DragEndDetails details) { if (!mounted) return; final threshold = 60.0; if (_dragExtent.abs() > threshold) { widget.onSwipe(); } _animation = Tween( begin: _dragExtent, end: 0, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); _controller.reset(); _controller.forward(); } // 🖼️ NEW: Handle image tap - open grid viewer void _handleImageTap(int tappedIndex) { if (widget.onImageTap == null) return; // Get all image URLs from uploaded documents final imageUrls = widget.message.uploadedDocuments .where((doc) => DownloadHelper.isImageFile(doc.fileName)) .map((doc) => doc.filePath) .toList(); if (imageUrls.isNotEmpty) { widget.onImageTap!(imageUrls, tappedIndex); } } Future _handleFileView(String url, String fileName) async { if (!mounted) return; try { print("👁️ Opening file: $fileName"); final existingPath = await DownloadHelper.checkFileExistsInReceived(url); if (existingPath != null) { await DownloadHelper.openDownloadedFile(existingPath); } else { final sendFolder = await DownloadHelper.getSendFolder(); final localPath = '${sendFolder.path}/$fileName'; final file = File(localPath); if (await file.exists()) { await DownloadHelper.openDownloadedFile(localPath); } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("File not found locally"), backgroundColor: Colors.orange, duration: Duration(seconds: 2), ), ); } } } } catch (e) { print("❌ View error: $e"); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Could not open file: $e"), backgroundColor: Colors.red, ), ); } } } Future _handleFileDownload(String url, String fileName) async { if (!mounted) return; setState(() { _downloadingFiles[fileName] = true; }); try { print("🔗 Downloading from: $url"); final result = await DownloadHelper.downloadFileToReceived(url); if (!mounted) return; if (result['success'] == true) { setState(() { _downloadedFiles[fileName] = true; }); if (mounted) { if (result['isNewDownload'] == false) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Opening $fileName"), backgroundColor: Colors.green, duration: const Duration(seconds: 1), ), ); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Downloaded $fileName"), backgroundColor: Colors.green, duration: const Duration(seconds: 1), ), ); } } } } catch (e) { print("❌ Download error: $e"); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Download failed: $e"), backgroundColor: Colors.red, ), ); } } finally { if (mounted) { setState(() { _downloadingFiles[fileName] = false; }); } } } Future _handleDownloadedFileView(String url, String fileName) async { if (!mounted) return; try { final existingPath = await DownloadHelper.checkFileExistsInReceived(url); if (existingPath != null) { await DownloadHelper.openDownloadedFile(existingPath); } } catch (e) { print("❌ View error: $e"); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Could not open file: $e"), backgroundColor: Colors.red, ), ); } } } @override Widget build(BuildContext context) { final isUserMessage = widget.message.chatBy == "user"; final hasDocuments = widget.message.uploadedDocuments.isNotEmpty; print("💬 Building message bubble:"); print(" - ID: ${widget.message.id}"); print(" - chatBy: ${widget.message.chatBy}"); print( " - uploadedDocuments count: ${widget.message.uploadedDocuments.length}", ); return Stack( children: [ if (_dragExtent.abs() > 10) Positioned( right: isUserMessage ? 20 : null, left: isUserMessage ? null : 20, top: 0, bottom: 0, child: Center( child: Icon( Icons.reply_rounded, color: Colors.grey[600], size: 24, ), ), ), GestureDetector( onHorizontalDragUpdate: _handleDragUpdate, onHorizontalDragEnd: _handleDragEnd, child: Transform.translate( offset: Offset(_dragExtent, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.message.user != null) Padding( padding: const EdgeInsets.only(left: 5, right: 5), child: Align( alignment: isUserMessage ? Alignment.centerRight : Alignment.centerLeft, child: Text( widget.message.user!.name, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black87, ), ), ), ), if (widget.message.parentTag != null) GestureDetector( onTap: () { if (widget.onParentTagTap != null) { widget.onParentTagTap!( widget.message.parentTag?.id ?? widget.message.tagId, ); } }, child: Align( alignment: isUserMessage ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.only( bottom: 4, left: 5, right: 5, ), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.60, ), decoration: BoxDecoration( color: Colors.black.withOpacity(0.05), borderRadius: BorderRadius.circular(8), border: Border.all( color: const Color(0xFF630A73).withOpacity(0.3), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 3, height: 30, decoration: BoxDecoration( color: const Color(0xFF630A73), borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 8), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.message.parentTag!.type == "file") Row( children: [ Icon( Icons.insert_drive_file, size: 14, color: Colors.grey[600], ), const SizedBox(width: 4), Expanded( child: Text( widget .message .parentTag! .fileName ?? "File", maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[700], ), ), ), ], ), Text( widget.message.parentTag!.message, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12, color: Colors.grey[700], ), ), ], ), ), ], ), ), ), ), Align( alignment: isUserMessage ? Alignment.centerRight : Alignment.centerLeft, child: ConstrainedBox( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.70, ), child: Container( margin: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric( vertical: 10, horizontal: 14, ), decoration: BoxDecoration( color: _isHighlighted ? Colors.green.withOpacity(0.2) : (isUserMessage ? const Color(0xFFFFF8E2) : Colors.white), border: Border.all( color: _isHighlighted ? Colors.green.withOpacity(0.5) : (isUserMessage ? const Color(0xFFF9D9C8) : const Color(0xFFE2E2E2)), ), borderRadius: BorderRadius.circular(10), boxShadow: isUserMessage ? [] : const [ BoxShadow( color: Color(0x40979797), blurRadius: 3.6, offset: Offset(0, 1), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ⭐⭐⭐ DISPLAY uploadedDocuments WITH IMAGE GRID SUPPORT if (hasDocuments) ...widget.message.uploadedDocuments.asMap().entries.map(( entry, ) { final index = entry.key; final doc = entry.value; final isImage = DownloadHelper.isImageFile( doc.fileName, ); final isDownloading = _downloadingFiles[doc.fileName] ?? false; final isDownloaded = _downloadedFiles[doc.fileName] ?? false; // 🖼️ Calculate image index (only count images before this one) final imageIndex = widget .message .uploadedDocuments .sublist(0, index) .where( (d) => DownloadHelper.isImageFile(d.fileName), ) .length; return GestureDetector( onTap: isImage ? () => _handleImageTap( imageIndex, ) // 🖼️ Open grid viewer : (isUserMessage ? () => _handleFileView( doc.filePath, doc.fileName, ) : null), child: Container( margin: const EdgeInsets.only(bottom: 8), child: isImage ? Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( doc.filePath, width: 150, height: 100, fit: BoxFit.cover, errorBuilder: (context, error, stack) { return Container( width: 200, height: 150, color: Colors.grey[300], child: const Center( child: Icon( Icons.broken_image, ), ), ); }, ), ), // ⭐ Download/View icon for receiver messages if (!isUserMessage) Positioned( top: 8, right: 8, child: GestureDetector( onTap: isDownloading ? null : isDownloaded ? () => _handleDownloadedFileView( doc.filePath, doc.fileName, ) : () => _handleFileDownload( doc.filePath, doc.fileName, ), child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.black .withOpacity(0.6), shape: BoxShape.circle, ), child: isDownloading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation< Color >( Colors .white, ), ), ) : Icon( isDownloaded ? Icons .visibility : Icons .download, color: Colors.white, size: 20, ), ), ), ), ], ) : Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular( 8, ), border: Border.all( color: Colors.grey[300]!, ), ), child: Row( children: [ Icon( Icons.insert_drive_file, color: Colors.grey[600], ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( doc.fileName, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), Text( doc.fileType .toUpperCase(), style: TextStyle( fontSize: 11, color: Colors.grey[600], ), ), ], ), ), GestureDetector( onTap: isDownloading ? null : () => isUserMessage ? _handleFileView( doc.filePath, doc.fileName, ) : isDownloaded ? _handleDownloadedFileView( doc.filePath, doc.fileName, ) : _handleFileDownload( doc.filePath, doc.fileName, ), child: isDownloading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Icon( isUserMessage ? Icons.open_in_new : isDownloaded ? Icons.visibility : Icons.download, color: Colors.grey[600], size: 20, ), ), ], ), ), ), ); }).toList(), if (widget.message.message.isNotEmpty) Text( widget.message.message, style: const TextStyle(fontSize: 15), ), const SizedBox(height: 3), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Text( widget.formatTime(widget.msgDate), style: TextStyle( fontSize: 11, color: Colors.grey[700], ), ), const SizedBox(width: 4), widget.buildTick( widget.message.isDelivered, widget.message.isRead, ), ], ), ], ), ), ), ), ], ), ), ), ], ); } }