397 lines
13 KiB
Dart
397 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/models/message.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/models/group_message.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/components/image_detail_screen.dart';
|
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
|
|
class MessageItem extends StatefulWidget {
|
|
final Message message;
|
|
final bool isMyMessage;
|
|
final bool isReadByAll;
|
|
final Function(Message) onReply;
|
|
final Function(Message) onLongPress;
|
|
final Function(LinkableElement)? onOpenLink;
|
|
|
|
const MessageItem({
|
|
super.key,
|
|
required this.message,
|
|
required this.isMyMessage,
|
|
this.isReadByAll = false,
|
|
required this.onReply,
|
|
required this.onLongPress,
|
|
this.onOpenLink,
|
|
});
|
|
|
|
@override
|
|
State<MessageItem> createState() => _MessageItemState();
|
|
}
|
|
|
|
class _MessageItemState extends State<MessageItem> {
|
|
Message get message => widget.message;
|
|
bool get isMyMessage => widget.isMyMessage;
|
|
bool get isReadByAll => widget.isReadByAll;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Align(
|
|
alignment: isMyMessage ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
margin: EdgeInsets.only(
|
|
left: isMyMessage ? 64 : 8,
|
|
right: isMyMessage ? 8 : 64,
|
|
bottom: 4,
|
|
top: 4,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
isMyMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
children: [
|
|
// Reply bubble if replying to another message
|
|
if (message.replyToId != null && message.replyToContent != null)
|
|
_buildReplyBubble(context),
|
|
|
|
// Message bubble
|
|
GestureDetector(
|
|
onTap:
|
|
message.imageUrl != null
|
|
? () => _openImageDetail(context)
|
|
: null,
|
|
onLongPress: () => widget.onLongPress(message),
|
|
child: _buildMessageBubble(context),
|
|
),
|
|
|
|
// Timestamp and status
|
|
_buildMessageFooter(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageBubble(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isMyMessage ? const Color(0xFF056839) : Colors.white,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: const Radius.circular(16),
|
|
topRight: const Radius.circular(16),
|
|
bottomLeft: Radius.circular(isMyMessage ? 16 : 4),
|
|
bottomRight: Radius.circular(isMyMessage ? 4 : 16),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
offset: const Offset(0, 1),
|
|
blurRadius: 3,
|
|
),
|
|
],
|
|
),
|
|
padding:
|
|
message.imageUrl != null
|
|
? const EdgeInsets.all(3) // Smaller padding for image messages
|
|
: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Sender name
|
|
if (!isMyMessage)
|
|
Padding(
|
|
padding:
|
|
message.imageUrl != null
|
|
? const EdgeInsets.only(
|
|
left: 8,
|
|
right: 8,
|
|
top: 6,
|
|
bottom: 2,
|
|
)
|
|
: const EdgeInsets.only(bottom: 4),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
message.senderUsername.isNotEmpty
|
|
? message.senderUsername
|
|
: message.senderEmail.split('@').first,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 13,
|
|
color: isMyMessage ? Colors.white70 : Colors.blueAccent,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Image if available
|
|
if (message.imageUrl != null) _buildMessageImage(context),
|
|
|
|
// Message content - always show text content even with images
|
|
if (message.content.isNotEmpty)
|
|
Padding(
|
|
padding:
|
|
message.imageUrl != null
|
|
? const EdgeInsets.only(
|
|
left: 8,
|
|
right: 8,
|
|
top: 6,
|
|
bottom: 6,
|
|
)
|
|
: EdgeInsets.zero,
|
|
child: Linkify(
|
|
onOpen: widget.onOpenLink,
|
|
text: message.content,
|
|
style: TextStyle(
|
|
color: isMyMessage ? Colors.white : Colors.black87,
|
|
fontSize: 15,
|
|
),
|
|
linkStyle: TextStyle(
|
|
color: isMyMessage ? Colors.lightBlue[100] : Colors.blue,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openImageDetail(BuildContext context) {
|
|
try {
|
|
// Precache the image to improve performance
|
|
if (message.imageUrl != null && message.imageUrl!.isNotEmpty) {
|
|
precacheImage(CachedNetworkImageProvider(message.imageUrl!), context);
|
|
}
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder:
|
|
(context) => ImageDetailScreen(
|
|
imageUrl: message.imageUrl ?? '',
|
|
senderName: message.senderUsername,
|
|
timestamp: message.createdAt,
|
|
heroTag: 'message-image-${message.id}',
|
|
),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
print('[ERROR] Failed to open image: $e');
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Failed to open image: $e')));
|
|
}
|
|
}
|
|
|
|
Widget _buildMessageImage(BuildContext context) {
|
|
// No image
|
|
if (message.imageUrl == null || message.imageUrl!.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
// If it's a local image, show it directly with a more WhatsApp-like loading indicator
|
|
if (message is GroupMessage &&
|
|
(message as GroupMessage).isLocalImage &&
|
|
(message as GroupMessage).localImageFile != null) {
|
|
return GestureDetector(
|
|
onTap: () => _openImageDetail(context),
|
|
child: Hero(
|
|
tag: 'message-image-${message.id}',
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
// Show the local image
|
|
Image.file(
|
|
(message as GroupMessage).localImageFile!,
|
|
width: double.infinity,
|
|
height: 200,
|
|
fit: BoxFit.cover,
|
|
),
|
|
// Semi-transparent overlay
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.3),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
// WhatsApp-style circular progress indicator
|
|
Container(
|
|
width: 50,
|
|
height: 50,
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Center(
|
|
child: CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
strokeWidth: 3,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Create a unique key for the image that includes both the URL and ID
|
|
// This forces the CachedNetworkImage to reload when the URL changes
|
|
final imageKey = '${message.id}-${message.imageUrl!}';
|
|
|
|
// Remote image with CachedNetworkImage
|
|
return GestureDetector(
|
|
onTap: () => _openImageDetail(context),
|
|
child: Hero(
|
|
tag: 'message-image-${message.id}',
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: CachedNetworkImage(
|
|
imageUrl: message.imageUrl!,
|
|
fadeInDuration: const Duration(milliseconds: 200),
|
|
fadeOutDuration: const Duration(milliseconds: 200),
|
|
memCacheWidth: 800,
|
|
maxWidthDiskCache: 1280,
|
|
placeholderFadeInDuration: const Duration(milliseconds: 200),
|
|
fit: BoxFit.cover,
|
|
// Use a unique key that changes when the image URL changes
|
|
key: ValueKey<String>(imageKey),
|
|
// Force cache refresh for images
|
|
cacheManager: DefaultCacheManager(),
|
|
progressIndicatorBuilder:
|
|
(context, url, downloadProgress) => Container(
|
|
height: 200,
|
|
width: double.infinity,
|
|
color: Colors.grey[200],
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
value: downloadProgress.progress ?? 0.0,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.green),
|
|
strokeWidth: 3,
|
|
),
|
|
),
|
|
),
|
|
errorWidget:
|
|
(context, url, error) => Container(
|
|
height: 200,
|
|
width: double.infinity,
|
|
color: Colors.grey[300],
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.broken_image, color: Colors.red, size: 40),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'Failed to load image',
|
|
style: TextStyle(color: Colors.red),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
// Force clear the cache for this image and try to reload
|
|
CachedNetworkImage.evictFromCache(url);
|
|
setState(() {});
|
|
},
|
|
child: Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildReplyBubble(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 3),
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
isMyMessage
|
|
? Colors.green.shade100.withOpacity(0.7)
|
|
: Colors.grey.shade100,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color:
|
|
isMyMessage
|
|
? const Color(0xFF056839).withOpacity(0.4)
|
|
: Colors.grey.withOpacity(0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
// Use username if available, otherwise fallback to email but hide domain
|
|
message.replyToSenderUsername ??
|
|
(message.replyToSenderEmail?.split('@').first ?? 'Unknown'),
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 11,
|
|
color: Color(0xFF056839),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
message.replyToContent ?? '',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[800]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageFooter() {
|
|
final timeFormat = DateFormat('HH:mm');
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 2, right: 4, left: 4),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
timeFormat.format(message.createdAt.toLocal()),
|
|
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
|
),
|
|
if (isMyMessage) ...[
|
|
const SizedBox(width: 4),
|
|
Icon(
|
|
isReadByAll ? Icons.done_all : Icons.done,
|
|
size: 12,
|
|
color: isReadByAll ? const Color(0xFF056839) : Colors.grey[600],
|
|
),
|
|
],
|
|
const Spacer(),
|
|
GestureDetector(
|
|
onTap: () => widget.onReply(message),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[200],
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'Balas',
|
|
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Global navigator key for context access
|
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|