feat: Implement About Us and Agent Home screens, update routing, and add new assets and plugins.
This commit is contained in:
@@ -4,9 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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/core/utils/image_url_resolver.dart';
|
||||
import 'package:real_estate_mobile/core/widgets/s3_image.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';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChatScreen extends ConsumerStatefulWidget {
|
||||
final String conversationId;
|
||||
@@ -102,10 +105,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
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 hour = dt.hour > 12
|
||||
? dt.hour - 12
|
||||
: (dt.hour == 0 ? 12 : dt.hour);
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
return '$hour.$minute';
|
||||
final amPm = dt.hour >= 12 ? 'PM' : 'AM';
|
||||
return '$hour:$minute $amPm';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
@@ -156,6 +161,18 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
return '$base/$avatarKey';
|
||||
}
|
||||
|
||||
String _formatFileSize(int? bytes) {
|
||||
if (bytes == null || bytes == 0) return '';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
bool _isGiphyUrl(String text) {
|
||||
final trimmed = text.trim();
|
||||
return trimmed.contains('giphy.com/') || trimmed.contains('giphy.gif');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BUILD
|
||||
// ============================================================
|
||||
@@ -219,7 +236,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HEADER - matches Figma: rounded border, back arrow, name, status, icons
|
||||
// HEADER
|
||||
// ============================================================
|
||||
|
||||
Widget _buildHeader({
|
||||
@@ -319,7 +336,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHAT BODY (profile section + messages)
|
||||
// CHAT BODY
|
||||
// ============================================================
|
||||
|
||||
Widget _buildChatBody({
|
||||
@@ -372,7 +389,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PROFILE SECTION (large avatar, name, specialties)
|
||||
// PROFILE SECTION
|
||||
// ============================================================
|
||||
|
||||
Widget _buildProfileSection({
|
||||
@@ -399,7 +416,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) =>
|
||||
errorWidget: (_, e, s) =>
|
||||
_AvatarFallback(letter: initials, size: 80),
|
||||
)
|
||||
: _AvatarFallback(letter: initials, size: 80),
|
||||
@@ -472,7 +489,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
|
||||
Widget _buildSpecialtiesRow(String headline) {
|
||||
// Split by comma, pipe, or similar separators
|
||||
final specialties = headline.split(RegExp(r'[,|;]'))
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
@@ -517,7 +533,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
required bool isTyping,
|
||||
required bool isLoadingMore,
|
||||
}) {
|
||||
// +1 for the profile section header at the end of reversed list
|
||||
final itemCount =
|
||||
messages.length + 1 + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0);
|
||||
|
||||
@@ -533,7 +548,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
|
||||
final adjustedIndex = isTyping ? index - 1 : index;
|
||||
|
||||
// Profile section at the top (last index in reversed list)
|
||||
final profileIndex = messages.length + (isLoadingMore ? 1 : 0);
|
||||
if (adjustedIndex == profileIndex) {
|
||||
return _buildProfileSection(
|
||||
@@ -570,7 +584,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
final showDateSeparator =
|
||||
_shouldShowDateSeparator(messages, adjustedIndex);
|
||||
|
||||
// Get sender info
|
||||
final senderName = isMine
|
||||
? (message.sender.displayName.isNotEmpty
|
||||
? message.sender.displayName
|
||||
@@ -634,9 +647,222 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Sent Message (right-aligned, with avatar on right) --
|
||||
// ============================================================
|
||||
// MESSAGE CONTENT (handles text, image, gif, file)
|
||||
// ============================================================
|
||||
|
||||
Widget _buildMessageContent(ChatMessage message, {required bool isMine}) {
|
||||
switch (message.messageType) {
|
||||
case MessageType.image:
|
||||
return _buildImageContent(message, isMine: isMine);
|
||||
case MessageType.file:
|
||||
return _buildFileContent(message);
|
||||
case MessageType.system:
|
||||
return _buildSystemContent(message);
|
||||
case MessageType.text:
|
||||
// Detect Giphy URLs sent as text and render as GIF image
|
||||
if (_isGiphyUrl(message.content)) {
|
||||
return _buildImageContent(message, isMine: isMine);
|
||||
}
|
||||
return Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
textAlign: isMine ? TextAlign.right : TextAlign.left,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Image / GIF content --
|
||||
Widget _buildImageContent(ChatMessage message, {required bool isMine}) {
|
||||
final imageUrl = message.fileUrl ?? message.content;
|
||||
|
||||
// GIF from Giphy (direct URL) or S3 image
|
||||
final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _showImageFullScreen(context, imageUrl),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 250,
|
||||
maxHeight: 250,
|
||||
),
|
||||
child: isDirectUrl
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => _buildImagePlaceholder(),
|
||||
errorWidget: (context, url, error) => _buildImageError(),
|
||||
)
|
||||
: S3Image(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_) => _buildImagePlaceholder(),
|
||||
errorWidget: (_) => _buildImageError(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePlaceholder() {
|
||||
return Container(
|
||||
width: 200,
|
||||
height: 150,
|
||||
color: const Color(0xFFF0F5FC),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.accentOrange,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageError() {
|
||||
return Container(
|
||||
width: 200,
|
||||
height: 150,
|
||||
color: const Color(0xFFF0F5FC),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.broken_image_outlined, size: 40, color: AppColors.hintText),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Image not available',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 12,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- File content --
|
||||
Widget _buildFileContent(ChatMessage message) {
|
||||
final fileName = message.fileName ?? 'File';
|
||||
final fileSize = _formatFileSize(message.fileSize);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _openOrDownloadFile(message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F5FC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentOrange.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.attach_file,
|
||||
size: 20,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fileName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (fileSize.isNotEmpty)
|
||||
Text(
|
||||
fileSize,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.download_rounded,
|
||||
size: 20,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- System message --
|
||||
Widget _buildSystemContent(ChatMessage message) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F5FC),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.hintText,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Message status indicator --
|
||||
Widget _buildStatusIcon(MessageStatus status) {
|
||||
switch (status) {
|
||||
case MessageStatus.read:
|
||||
return const Icon(Icons.done_all, size: 14, color: Color(0xFF2196F3));
|
||||
case MessageStatus.delivered:
|
||||
return Icon(Icons.done_all, size: 14, color: AppColors.hintText);
|
||||
case MessageStatus.sent:
|
||||
return Icon(Icons.done, size: 14, color: AppColors.hintText);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SENT MESSAGE (right-aligned)
|
||||
// ============================================================
|
||||
|
||||
Widget _buildSentMessage(ChatMessage message, String senderName) {
|
||||
final timestamp = _formatTime(message.createdAt);
|
||||
final isMediaMessage = message.messageType == MessageType.image ||
|
||||
message.messageType == MessageType.file;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
@@ -648,10 +874,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Name row with timestamp
|
||||
// Name row with timestamp and status
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatusIcon(message.status),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
timestamp,
|
||||
style: const TextStyle(
|
||||
@@ -693,17 +921,23 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Message text
|
||||
Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
// Message content
|
||||
_buildMessageContent(message, isMine: true),
|
||||
// Show text content below media if present
|
||||
if (isMediaMessage && message.content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -714,10 +948,15 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Received Message (left-aligned, with avatar on left) --
|
||||
// ============================================================
|
||||
// RECEIVED MESSAGE (left-aligned)
|
||||
// ============================================================
|
||||
|
||||
Widget _buildReceivedMessage(
|
||||
ChatMessage message, String senderName, String? avatar) {
|
||||
final timestamp = _formatTime(message.createdAt);
|
||||
final isMediaMessage = message.messageType == MessageType.image ||
|
||||
message.messageType == MessageType.file;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
@@ -776,16 +1015,22 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Message text
|
||||
Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
// Message content
|
||||
_buildMessageContent(message, isMine: false),
|
||||
// Show text content below media if present
|
||||
if (isMediaMessage && message.content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -806,7 +1051,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) =>
|
||||
errorWidget: (_, e, s) =>
|
||||
_AvatarFallback(letter: initials, size: size),
|
||||
)
|
||||
: _AvatarFallback(letter: initials, size: size),
|
||||
@@ -854,7 +1099,83 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INPUT AREA - matches Figma: + circle, text input, send, mic circle
|
||||
// IMAGE FULL SCREEN VIEWER
|
||||
// ============================================================
|
||||
|
||||
void _showImageFullScreen(BuildContext context, String imageUrl) {
|
||||
final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 4.0,
|
||||
child: isDirectUrl
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
errorWidget: (context, url, error) => const Icon(
|
||||
Icons.broken_image,
|
||||
size: 80,
|
||||
color: Colors.white54,
|
||||
),
|
||||
)
|
||||
: S3Image(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_) => const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
errorWidget: (_) => const Icon(
|
||||
Icons.broken_image,
|
||||
size: 80,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FILE OPEN / DOWNLOAD
|
||||
// ============================================================
|
||||
|
||||
Future<void> _openOrDownloadFile(ChatMessage message) async {
|
||||
final fileUrl = message.fileUrl;
|
||||
if (fileUrl == null || fileUrl.isEmpty) return;
|
||||
|
||||
String? url;
|
||||
if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
|
||||
url = fileUrl;
|
||||
} else {
|
||||
// Resolve S3 key to presigned download URL
|
||||
url = await ImageUrlResolver.instance.resolve(fileUrl);
|
||||
}
|
||||
|
||||
if (url != null && url.isNotEmpty) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INPUT AREA
|
||||
// ============================================================
|
||||
|
||||
Widget _buildInputArea() {
|
||||
@@ -864,7 +1185,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Plus icon (plain, no border)
|
||||
// Plus icon
|
||||
GestureDetector(
|
||||
onTap: _showAttachmentOptions,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -881,15 +1202,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
// Text input pill with send icon inside
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 40,
|
||||
height: 44,
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -913,8 +1236,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
horizontal: 18,
|
||||
vertical: 10,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
@@ -927,7 +1250,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
onTap: _sendMessage,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
padding: const EdgeInsets.only(right: 14),
|
||||
child: Icon(
|
||||
Icons.send,
|
||||
size: 20,
|
||||
|
||||
Reference in New Issue
Block a user