304 lines
9.5 KiB
Dart
304 lines
9.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
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:intl/intl.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/components/image_detail_screen.dart';
|
|
|
|
class MessageItem extends StatelessWidget {
|
|
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,
|
|
required this.isReadByAll,
|
|
required this.onReply,
|
|
required this.onLongPress,
|
|
required this.onOpenLink,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 2),
|
|
child: GestureDetector(
|
|
onLongPress: () => onLongPress(message),
|
|
child: Row(
|
|
mainAxisAlignment:
|
|
isMyMessage ? MainAxisAlignment.end : MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
// Only show avatar for messages from others
|
|
if (!isMyMessage) _buildAvatar(),
|
|
|
|
// Message bubble with swipe to reply
|
|
Flexible(
|
|
child: Dismissible(
|
|
key: Key('dismissible-${message.id}'),
|
|
direction: DismissDirection.startToEnd,
|
|
dismissThresholds: const {DismissDirection.startToEnd: 0.2},
|
|
confirmDismiss: (_) async {
|
|
onReply(message);
|
|
return false;
|
|
},
|
|
background: Container(
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.only(left: 8),
|
|
color: Colors.green.shade100,
|
|
width: 80,
|
|
child: Icon(
|
|
Icons.reply,
|
|
color: Colors.green.shade700,
|
|
size: 18,
|
|
),
|
|
),
|
|
child: _buildMessageBubble(context),
|
|
),
|
|
),
|
|
|
|
// Space after my messages
|
|
if (isMyMessage) const SizedBox(width: 12),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAvatar() {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: Colors.grey[300],
|
|
backgroundImage:
|
|
message.avatarUrl != null && message.avatarUrl!.isNotEmpty
|
|
? CachedNetworkImageProvider(
|
|
message.avatarUrl!,
|
|
maxHeight: 64,
|
|
maxWidth: 64,
|
|
)
|
|
as ImageProvider
|
|
: null,
|
|
child:
|
|
(message.avatarUrl == null || message.avatarUrl!.isEmpty)
|
|
? Text(
|
|
message.senderUsername.isNotEmpty
|
|
? message.senderUsername[0].toUpperCase()
|
|
: '?',
|
|
style: TextStyle(
|
|
color: Colors.black54,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageBubble(BuildContext context) {
|
|
return Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isMyMessage ? Color(0xFFDCF8C6) : Colors.white,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(isMyMessage ? 8 : 0),
|
|
topRight: Radius.circular(isMyMessage ? 0 : 8),
|
|
bottomLeft: const Radius.circular(8),
|
|
bottomRight: const Radius.circular(8),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.08),
|
|
blurRadius: 3,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
margin: EdgeInsets.only(
|
|
bottom: 1,
|
|
left: isMyMessage ? 40 : 0,
|
|
right: isMyMessage ? 0 : 40,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Sender name (only for messages from others)
|
|
if (!isMyMessage)
|
|
Text(
|
|
message.senderUsername,
|
|
style: TextStyle(
|
|
color: Colors.green[700],
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 13,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
|
|
// Show reply preview if this is a reply
|
|
if (message.replyToId != null && message.replyToContent != null)
|
|
_buildReplyPreview(),
|
|
|
|
// Message content
|
|
if (message.imageUrl != null && message.imageUrl!.isNotEmpty)
|
|
_buildImagePreview(context),
|
|
|
|
if (message.content.isNotEmpty)
|
|
Linkify(
|
|
onOpen: onOpenLink,
|
|
text: message.content,
|
|
style: const TextStyle(color: Colors.black87, fontSize: 15),
|
|
linkStyle: const TextStyle(
|
|
color: Colors.blue,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
options: LinkifyOptions(humanize: false),
|
|
),
|
|
|
|
// Time indicator with read status
|
|
Align(
|
|
alignment: Alignment.bottomRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: _buildTimeWithStatus(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildReplyPreview() {
|
|
// Extract username from reply
|
|
String replyUsername = message.replyToSenderEmail ?? 'Unknown';
|
|
if (replyUsername.contains('@')) {
|
|
replyUsername = replyUsername.split('@')[0];
|
|
}
|
|
|
|
final replyContent = message.replyToContent ?? 'No content';
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 4),
|
|
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade100,
|
|
border: Border(left: BorderSide(color: Colors.blue.shade400, width: 2)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
replyUsername,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: 10,
|
|
color: Colors.blue.shade700,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
replyContent.length > 40
|
|
? '${replyContent.substring(0, 40)}...'
|
|
: replyContent,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 10, color: Colors.grey.shade800),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildImagePreview(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 4, top: 2),
|
|
child: GestureDetector(
|
|
onTap: () => _openImageDetail(context),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: CachedNetworkImage(
|
|
imageUrl: message.imageUrl!,
|
|
placeholder:
|
|
(context, url) => Container(
|
|
height: 200,
|
|
color: Colors.grey[200],
|
|
child: const Center(child: CircularProgressIndicator()),
|
|
),
|
|
errorWidget:
|
|
(context, url, error) => Container(
|
|
height: 200,
|
|
color: Colors.grey[200],
|
|
child: const Center(child: Icon(Icons.error)),
|
|
),
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openImageDetail(BuildContext context) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder:
|
|
(context) => ImageDetailScreen(
|
|
imageUrl: message.imageUrl!,
|
|
senderName: message.senderUsername,
|
|
timestamp: message.createdAt,
|
|
heroTag: 'message-image-${message.id}',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTimeWithStatus() {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
_formatTime(message.createdAt),
|
|
style: TextStyle(color: Colors.grey[600], fontSize: 11),
|
|
),
|
|
if (isMyMessage)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 3),
|
|
child: Icon(
|
|
isReadByAll ? Icons.done_all : Icons.done,
|
|
size: 14,
|
|
color: isReadByAll ? Colors.blue[400] : Colors.grey[400],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _formatTime(DateTime dateTime) {
|
|
final now = DateTime.now();
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
final yesterday = today.subtract(Duration(days: 1));
|
|
final messageDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
|
|
|
|
if (messageDate == today) {
|
|
return DateFormat('HH:mm').format(dateTime);
|
|
} else if (messageDate == yesterday) {
|
|
return 'Kemarin ${DateFormat('HH:mm').format(dateTime)}';
|
|
} else {
|
|
return DateFormat('dd/MM HH:mm').format(dateTime);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Global navigator key for context access
|
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|