688 lines
30 KiB
Dart
688 lines
30 KiB
Dart
// ⭐⭐⭐ 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(MessageModel) buildTick;
|
|
final VoidCallback onSwipe;
|
|
final Function(int?)? onParentTagTap;
|
|
final Function(List<String> 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<SwipeableMessageBubble> createState() => SwipeableMessageBubbleState();
|
|
}
|
|
|
|
class SwipeableMessageBubbleState extends State<SwipeableMessageBubble>
|
|
with SingleTickerProviderStateMixin {
|
|
double _dragExtent = 0;
|
|
late AnimationController _controller;
|
|
late Animation<double> _animation;
|
|
final Map<String, bool> _downloadingFiles = {};
|
|
final Map<String, bool> _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<double>(begin: 0, end: 0).animate(_controller)
|
|
..addListener(() {
|
|
setState(() {
|
|
_dragExtent = _animation.value;
|
|
});
|
|
});
|
|
|
|
_checkDownloadedFiles();
|
|
}
|
|
|
|
Future<void> _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<double>(
|
|
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<void> _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<void> _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<void> _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),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|