feat: Implement messaging functionality with conversation and chat screens, and enhance agent detail views.
This commit is contained in:
907
lib/features/messaging/presentation/screens/chat_screen.dart
Normal file
907
lib/features/messaging/presentation/screens/chat_screen.dart
Normal file
@@ -0,0 +1,907 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:real_estate_mobile/config/app_config.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
|
||||
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
|
||||
|
||||
class ChatScreen extends ConsumerStatefulWidget {
|
||||
final String conversationId;
|
||||
|
||||
const ChatScreen({super.key, required this.conversationId});
|
||||
|
||||
@override
|
||||
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final FocusNode _messageFocusNode = FocusNode();
|
||||
bool _isComposing = false;
|
||||
int _previousMessageCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final notifier = ref.read(messagingProvider.notifier);
|
||||
notifier.setActiveConversation(widget.conversationId);
|
||||
notifier.loadMessages(widget.conversationId);
|
||||
notifier.markAsRead(widget.conversationId);
|
||||
});
|
||||
_scrollController.addListener(_onScroll);
|
||||
_messageController.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ref.read(messagingProvider.notifier).setActiveConversation(null);
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_messageFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 50) {
|
||||
ref
|
||||
.read(messagingProvider.notifier)
|
||||
.loadMessages(widget.conversationId, loadMore: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final composing = _messageController.text.trim().isNotEmpty;
|
||||
if (composing != _isComposing) {
|
||||
setState(() => _isComposing = composing);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom({bool animated = true}) {
|
||||
if (!_scrollController.hasClients) return;
|
||||
if (animated) {
|
||||
_scrollController.animateTo(
|
||||
0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} else {
|
||||
_scrollController.jumpTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
ref
|
||||
.read(messagingProvider.notifier)
|
||||
.sendMessage(widget.conversationId, text);
|
||||
_messageController.clear();
|
||||
setState(() => _isComposing = false);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
ref.read(messagingProvider.notifier).setActiveConversation(null);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DATE / TIME FORMATTING
|
||||
// ============================================================
|
||||
|
||||
String _formatTime(String isoTimestamp) {
|
||||
try {
|
||||
final dt = DateTime.parse(isoTimestamp).toLocal();
|
||||
final hour =
|
||||
dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour);
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
return '$hour.$minute';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateSeparator(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final messageDate = DateTime(date.year, date.month, date.day);
|
||||
final diff = today.difference(messageDate).inDays;
|
||||
|
||||
if (diff == 0) return 'Today';
|
||||
if (diff == 1) return 'Yesterday';
|
||||
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
||||
}
|
||||
|
||||
bool _shouldShowDateSeparator(List<ChatMessage> messages, int index) {
|
||||
if (index == messages.length - 1) return true;
|
||||
|
||||
final current = DateTime.parse(messages[index].createdAt).toLocal();
|
||||
final older = DateTime.parse(messages[index + 1].createdAt).toLocal();
|
||||
|
||||
return DateTime(current.year, current.month, current.day) !=
|
||||
DateTime(older.year, older.month, older.day);
|
||||
}
|
||||
|
||||
String _getInitials(String name) {
|
||||
final parts = name.trim().split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty) return '';
|
||||
if (parts.length == 1) return parts[0][0].toUpperCase();
|
||||
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
|
||||
}
|
||||
|
||||
String _resolveAvatarUrl(String? avatarKey) {
|
||||
if (avatarKey == null || avatarKey.isEmpty) return '';
|
||||
if (avatarKey.startsWith('http://') || avatarKey.startsWith('https://')) {
|
||||
return avatarKey;
|
||||
}
|
||||
final base = AppConfig.storageBaseUrl;
|
||||
if (avatarKey.startsWith('/uploads')) {
|
||||
return '$base$avatarKey';
|
||||
}
|
||||
return '$base/$avatarKey';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BUILD
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messagingState = ref.watch(messagingProvider);
|
||||
final currentUserId = ref.read(authProvider).user?.id;
|
||||
final messages = messagingState.messages[widget.conversationId] ?? [];
|
||||
final isTyping = messagingState.typingUsers.isNotEmpty;
|
||||
|
||||
final conversation = messagingState.conversations
|
||||
.cast<Conversation?>()
|
||||
.firstWhere(
|
||||
(c) => c!.id == widget.conversationId,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (messages.length > _previousMessageCount && _previousMessageCount > 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom(animated: true);
|
||||
});
|
||||
}
|
||||
_previousMessageCount = messages.length;
|
||||
|
||||
final otherPartyName = conversation?.otherParty.name ?? 'Chat';
|
||||
final otherPartyAvatar = conversation?.otherParty.avatar;
|
||||
final isOnline = conversation?.otherParty.isOnline ?? false;
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(
|
||||
name: otherPartyName,
|
||||
isOnline: isOnline,
|
||||
),
|
||||
Expanded(
|
||||
child: messagingState.isLoadingMessages && messages.isEmpty
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.accentOrange,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: messages.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No messages yet.\nSay hello!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _buildMessagesList(
|
||||
messages: messages,
|
||||
currentUserId: currentUserId ?? '',
|
||||
currentUserName: 'You',
|
||||
otherPartyName: otherPartyName,
|
||||
otherPartyAvatar: otherPartyAvatar,
|
||||
isTyping: isTyping,
|
||||
isLoadingMore: messagingState.isLoadingMessages &&
|
||||
messages.isNotEmpty,
|
||||
),
|
||||
),
|
||||
_buildInputArea(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HEADER - matches Figma: rounded border, back arrow, name, status, icons
|
||||
// ============================================================
|
||||
|
||||
Widget _buildHeader({
|
||||
required String name,
|
||||
required bool isOnline,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Container(
|
||||
height: 55,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark,
|
||||
width: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Back arrow
|
||||
GestureDetector(
|
||||
onTap: _handleBack,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
size: 23,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Name and active status
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
if (isOnline) ...[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF4CAF50),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Text(
|
||||
isOnline ? 'Active Now' : 'Offline',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Three dots (vertical)
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 20,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Star icon
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 14),
|
||||
child: Icon(
|
||||
Icons.star_rounded,
|
||||
size: 24,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MESSAGES LIST
|
||||
// ============================================================
|
||||
|
||||
Widget _buildMessagesList({
|
||||
required List<ChatMessage> messages,
|
||||
required String currentUserId,
|
||||
required String currentUserName,
|
||||
required String otherPartyName,
|
||||
String? otherPartyAvatar,
|
||||
required bool isTyping,
|
||||
required bool isLoadingMore,
|
||||
}) {
|
||||
final itemCount =
|
||||
messages.length + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0);
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (isTyping && index == 0) {
|
||||
return _buildTypingIndicator();
|
||||
}
|
||||
|
||||
final adjustedIndex = isTyping ? index - 1 : index;
|
||||
|
||||
if (isLoadingMore && adjustedIndex == messages.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.accentOrange,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (adjustedIndex < 0 || adjustedIndex >= messages.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final message = messages[adjustedIndex];
|
||||
final isMine = message.isMine(currentUserId);
|
||||
|
||||
final showDateSeparator =
|
||||
_shouldShowDateSeparator(messages, adjustedIndex);
|
||||
|
||||
// Get sender info
|
||||
final senderName = isMine
|
||||
? (message.sender.displayName.isNotEmpty
|
||||
? message.sender.displayName
|
||||
: currentUserName)
|
||||
: otherPartyName;
|
||||
final senderAvatar = isMine ? null : otherPartyAvatar;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (showDateSeparator)
|
||||
_buildDateSeparator(
|
||||
DateTime.parse(message.createdAt).toLocal(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: isMine
|
||||
? _buildSentMessage(message, senderName)
|
||||
: _buildReceivedMessage(message, senderName, senderAvatar),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- Date Separator --
|
||||
Widget _buildDateSeparator(DateTime date) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
thickness: 0.5,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
_formatDateSeparator(date),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.subtleText,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
thickness: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Sent Message (right-aligned, with avatar on right) --
|
||||
Widget _buildSentMessage(ChatMessage message, String senderName) {
|
||||
final timestamp = _formatTime(message.createdAt);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Name row with timestamp
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
timestamp,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w200,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
senderName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.accentOrange,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Message text
|
||||
Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Avatar
|
||||
_buildMessageAvatar(null, senderName, 45),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// -- Received Message (left-aligned, with avatar on left) --
|
||||
Widget _buildReceivedMessage(
|
||||
ChatMessage message, String senderName, String? avatar) {
|
||||
final timestamp = _formatTime(message.createdAt);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar
|
||||
_buildMessageAvatar(avatar, senderName, 45),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Name row with timestamp
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
senderName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.accentOrange,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
timestamp,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w200,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Message text
|
||||
Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// -- Message Avatar --
|
||||
Widget _buildMessageAvatar(String? avatarKey, String name, double size) {
|
||||
final avatarUrl = _resolveAvatarUrl(avatarKey);
|
||||
final initials = _getInitials(name);
|
||||
|
||||
return ClipOval(
|
||||
child: avatarUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: avatarUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) =>
|
||||
_AvatarFallback(letter: initials, size: size),
|
||||
)
|
||||
: _AvatarFallback(letter: initials, size: size),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Typing Indicator --
|
||||
Widget _buildTypingIndicator() {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDot(0),
|
||||
const SizedBox(width: 4),
|
||||
_buildDot(1),
|
||||
const SizedBox(width: 4),
|
||||
_buildDot(2),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDot(int index) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.3, end: 1.0),
|
||||
duration: Duration(milliseconds: 600 + (index * 200)),
|
||||
builder: (context, value, child) {
|
||||
return Opacity(
|
||||
opacity: value,
|
||||
child: Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.subtleText,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INPUT AREA - matches Figma: + circle, text input, send, mic circle
|
||||
// ============================================================
|
||||
|
||||
Widget _buildInputArea() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Plus icon in circle border
|
||||
GestureDetector(
|
||||
onTap: _showAttachmentOptions,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
size: 24,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Text input
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
focusNode: _messageFocusNode,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Write a Message',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Send icon inside the input field
|
||||
GestureDetector(
|
||||
onTap: _sendMessage,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Icon(
|
||||
Icons.send,
|
||||
size: 20,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Mic icon in circle border
|
||||
GestureDetector(
|
||||
onTap: () => _showComingSoon('Speech to text'),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.mic_none,
|
||||
size: 20,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPERS
|
||||
// ============================================================
|
||||
|
||||
void _showComingSoon(String feature) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$feature coming soon'),
|
||||
duration: const Duration(seconds: 1),
|
||||
backgroundColor: AppColors.primaryDark,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAttachmentOptions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.divider,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildAttachmentOption(
|
||||
icon: Icons.image_outlined,
|
||||
label: 'Photo',
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_showComingSoon('Photo attachment');
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
icon: Icons.attach_file,
|
||||
label: 'Document',
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_showComingSoon('Document attachment');
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
icon: Icons.location_on_outlined,
|
||||
label: 'Location',
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_showComingSoon('Location sharing');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentOption({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: AppColors.primaryDark),
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Avatar Fallback --
|
||||
|
||||
class _AvatarFallback extends StatelessWidget {
|
||||
final String letter;
|
||||
final double size;
|
||||
|
||||
const _AvatarFallback({required this.letter, this.size = 45});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFE8E8E8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: size * 0.38,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:real_estate_mobile/config/app_config.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
|
||||
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
|
||||
|
||||
class ConversationsScreen extends ConsumerStatefulWidget {
|
||||
const ConversationsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConversationsScreen> createState() =>
|
||||
_ConversationsScreenState();
|
||||
}
|
||||
|
||||
class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
bool _isSearchActive = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(messagingProvider.notifier).loadConversations();
|
||||
});
|
||||
_searchController.addListener(() {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text.trim().toLowerCase();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Conversation> _filteredConversations(List<Conversation> conversations) {
|
||||
if (_searchQuery.isEmpty) return conversations;
|
||||
return conversations
|
||||
.where((c) => c.otherParty.name.toLowerCase().contains(_searchQuery))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(messagingProvider);
|
||||
final filtered = _filteredConversations(state.conversations);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(child: _buildConversationList(state, filtered)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Header with back arrow, search, icons --
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
child: Container(
|
||||
height: 51,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark,
|
||||
width: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Back arrow
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (_isSearchActive) {
|
||||
setState(() {
|
||||
_isSearchActive = false;
|
||||
_searchController.clear();
|
||||
});
|
||||
} else {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Icon(
|
||||
Icons.arrow_back_ios_new,
|
||||
size: 17,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Search text or input
|
||||
Expanded(
|
||||
child: _isSearchActive
|
||||
? TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search Messages',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
isDense: true,
|
||||
),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: () => setState(() => _isSearchActive = true),
|
||||
child: const Text(
|
||||
'Search Messages',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Search icon
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isSearchActive = true),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.search,
|
||||
size: 20,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Three dots menu
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12, left: 4),
|
||||
child: Icon(
|
||||
Icons.more_horiz,
|
||||
size: 20,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Shimmer Loading State --
|
||||
Widget _buildShimmerLoading() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: const Color(0xFFE8E8E8),
|
||||
highlightColor: const Color(0xFFF5F5F5),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: 6,
|
||||
itemBuilder: (_, index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 130,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 45,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Conversation List --
|
||||
Widget _buildConversationList(
|
||||
MessagingState state, List<Conversation> filtered) {
|
||||
if (state.isLoadingConversations) {
|
||||
return _buildShimmerLoading();
|
||||
}
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
color: AppColors.hintText,
|
||||
size: 56,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'No conversations found'
|
||||
: 'No messages yet',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Try a different search term'
|
||||
: 'Start a conversation with a professional',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.subtleText,
|
||||
),
|
||||
),
|
||||
if (state.error != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
TextButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(messagingProvider.notifier).loadConversations(),
|
||||
icon: const Icon(
|
||||
Icons.refresh,
|
||||
color: AppColors.accentOrange,
|
||||
size: 18,
|
||||
),
|
||||
label: const Text(
|
||||
'Retry',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.accentOrange,
|
||||
onRefresh: () =>
|
||||
ref.read(messagingProvider.notifier).loadConversations(),
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
height: 0.5,
|
||||
thickness: 0.5,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
return _ConversationTile(
|
||||
conversation: filtered[index],
|
||||
onTap: () =>
|
||||
context.push('/messages/chat/${filtered[index].id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Conversation Tile Widget --
|
||||
|
||||
class _ConversationTile extends StatelessWidget {
|
||||
final Conversation conversation;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ConversationTile({
|
||||
required this.conversation,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
String _resolveAvatarUrl(String? avatarKey) {
|
||||
if (avatarKey == null || avatarKey.isEmpty) return '';
|
||||
if (avatarKey.startsWith('http://') || avatarKey.startsWith('https://')) {
|
||||
return avatarKey;
|
||||
}
|
||||
final base = AppConfig.storageBaseUrl;
|
||||
if (avatarKey.startsWith('/uploads')) {
|
||||
return '$base$avatarKey';
|
||||
}
|
||||
return '$base/$avatarKey';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final otherParty = conversation.otherParty;
|
||||
final avatarUrl = _resolveAvatarUrl(otherParty.avatar);
|
||||
final lastMessage = conversation.lastMessageText ?? '';
|
||||
final timestamp = _formatTimestamp(conversation.lastMessageAt);
|
||||
final firstLetter = otherParty.name.isNotEmpty
|
||||
? otherParty.name[0].toUpperCase()
|
||||
: '?';
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar with online indicator
|
||||
Stack(
|
||||
children: [
|
||||
ClipOval(
|
||||
child: avatarUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: avatarUrl,
|
||||
width: 52,
|
||||
height: 52,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, url) => _AvatarFallback(
|
||||
letter: firstLetter,
|
||||
),
|
||||
errorWidget: (_, url, err) => _AvatarFallback(
|
||||
letter: firstLetter,
|
||||
),
|
||||
)
|
||||
: _AvatarFallback(letter: firstLetter),
|
||||
),
|
||||
if (otherParty.isOnline)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4CAF50),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Name and last message
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
otherParty.name,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
lastMessage,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Timestamp
|
||||
Text(
|
||||
timestamp,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Avatar Fallback --
|
||||
|
||||
class _AvatarFallback extends StatelessWidget {
|
||||
final String letter;
|
||||
|
||||
const _AvatarFallback({required this.letter});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFE8E8E8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
letter,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Timestamp Formatting --
|
||||
|
||||
String _formatTimestamp(String? isoTimestamp) {
|
||||
if (isoTimestamp == null || isoTimestamp.isEmpty) return '';
|
||||
|
||||
final dateTime = DateTime.tryParse(isoTimestamp);
|
||||
if (dateTime == null) return '';
|
||||
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final messageDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
|
||||
final localTime = dateTime.toLocal();
|
||||
final diff = now.difference(dateTime);
|
||||
|
||||
// "Now" for messages within the last 2 minutes
|
||||
if (diff.inMinutes < 2) {
|
||||
return 'Now';
|
||||
}
|
||||
|
||||
// Same day: show time like "2 hours Ago"
|
||||
if (messageDate == today) {
|
||||
if (diff.inHours >= 1) {
|
||||
return '${diff.inHours} hour${diff.inHours > 1 ? 's' : ''} Ago';
|
||||
}
|
||||
return '${diff.inMinutes} min Ago';
|
||||
}
|
||||
|
||||
// Within last 30 days: show "X Days Ago"
|
||||
final daysDiff = today.difference(messageDate).inDays;
|
||||
if (daysDiff == 1) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
if (daysDiff <= 30) {
|
||||
return '$daysDiff Days Ago';
|
||||
}
|
||||
|
||||
// Older: show month and day
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
return '${months[localTime.month - 1]} ${localTime.day}';
|
||||
}
|
||||
Reference in New Issue
Block a user