Files
mobile-app/lib/features/messaging/presentation/screens/chat_screen.dart

908 lines
28 KiB
Dart

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,
),
),
);
}
}