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

1732 lines
56 KiB
Dart
Raw Normal View History

import 'dart:async';
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: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/core/services/push_notification_service.dart';
import 'package:real_estate_mobile/features/messaging/data/socket_service.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;
const ChatScreen({super.key, required this.conversationId});
@override
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen>
with WidgetsBindingObserver {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final FocusNode _messageFocusNode = FocusNode();
bool _isComposing = false;
bool _isMuted = false;
bool _isStarred = false;
int _previousMessageCount = 0;
final SocketService _socket = SocketService();
StreamSubscription<bool>? _connectionSub;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
final notifier = ref.read(messagingProvider.notifier);
notifier.setActiveConversation(widget.conversationId);
notifier.loadMessages(widget.conversationId);
notifier.markAsRead(widget.conversationId);
// Initialize mute/star state from conversation data
final conversations = ref.read(messagingProvider).conversations;
final conv = conversations.where((c) => c.id == widget.conversationId).firstOrNull;
if (conv != null) {
final currentUserId = ref.read(currentUserIdProvider);
final isUser = conv.userId == currentUserId;
setState(() {
_isMuted = isUser ? conv.userMuted : conv.agentMuted;
_isStarred = isUser ? conv.userFavorited : conv.agentFavorited;
});
}
// Suppress push notifications for this conversation while viewing
PushNotificationService().activeConversationId = widget.conversationId;
// Ensure socket is connected and join room for real-time updates
_socket.ensureConnected().then((_) {
if (_socket.isConnected) {
_socket.joinConversation(widget.conversationId);
}
});
// Listen for ALL connection changes (initial connect + reconnects)
// so we re-join the room after any reconnection
_connectionSub = _socket.onConnectionChange.listen((connected) {
if (connected && mounted) {
_socket.joinConversation(widget.conversationId);
}
});
});
_scrollController.addListener(_onScroll);
_messageController.addListener(_onTextChanged);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// App came back to foreground — reconnect socket and refresh messages
_socket.ensureConnected().then((_) {
if (_socket.isConnected && mounted) {
_socket.joinConversation(widget.conversationId);
}
});
// Refresh messages to catch anything missed while in background
ref.read(messagingProvider.notifier).loadMessages(widget.conversationId);
}
}
@override
void dispose() {
// Defer provider modification to avoid "modify during build" error
final notifier = ref.read(messagingProvider.notifier);
Future.microtask(() => notifier.setActiveConversation(null));
WidgetsBinding.instance.removeObserver(this);
_connectionSub?.cancel();
// Stop typing and leave room before disposing
if (_isComposing) {
_socket.stopTyping(widget.conversationId);
}
_socket.leaveConversation(widget.conversationId);
PushNotificationService().activeConversationId = 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);
// Emit typing indicator via socket
if (composing) {
_socket.startTyping(widget.conversationId);
} else {
_socket.stopTyping(widget.conversationId);
}
}
}
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;
_socket.stopTyping(widget.conversationId);
ref
.read(messagingProvider.notifier)
.sendMessage(widget.conversationId, text);
_messageController.clear();
setState(() => _isComposing = false);
Future.delayed(const Duration(milliseconds: 100), () {
_scrollToBottom();
});
}
void _handleMenuAction(String action, String? agentProfileId) {
switch (action) {
case 'clear_chat':
_showConfirmDialog(
title: 'Clear Chat',
message: 'Are you sure you want to clear all messages? This cannot be undone.',
onConfirm: () async {
await ref.read(messagingProvider.notifier).clearChat(widget.conversationId);
},
);
break;
case 'mute':
final newMuted = !_isMuted;
setState(() => _isMuted = newMuted);
ref.read(messagingProvider.notifier).toggleMute(widget.conversationId, newMuted);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newMuted ? 'Notifications muted' : 'Notifications unmuted'),
duration: const Duration(seconds: 2),
),
);
break;
case 'report':
_showReportDialog();
break;
case 'delete':
_showConfirmDialog(
title: 'Delete Conversation',
message: 'Are you sure you want to delete this conversation? This cannot be undone.',
onConfirm: () async {
final notifier = ref.read(messagingProvider.notifier);
await notifier.deleteConversation(widget.conversationId);
if (mounted) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/messages');
}
}
},
isDanger: true,
);
break;
}
}
void _showConfirmDialog({
required String title,
required String message,
required Future<void> Function() onConfirm,
bool isDanger = false,
}) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
onConfirm();
},
child: Text(
'Confirm',
style: TextStyle(color: isDanger ? Colors.red : null),
),
),
],
),
);
}
void _showReportDialog() {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Report User'),
content: TextField(
controller: controller,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'Describe the issue...',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Report submitted. Thank you.')),
);
},
child: const Text('Submit'),
),
],
),
);
}
void _handleBack() {
final notifier = ref.read(messagingProvider.notifier);
Future.microtask(() => notifier.setActiveConversation(null));
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/messages');
}
}
// ============================================================
// 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');
final amPm = dt.hour >= 12 ? 'PM' : 'AM';
return '$hour:$minute $amPm';
} 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 _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');
}
bool _isGifUrl(String url) {
final lower = url.trim().toLowerCase();
return lower.endsWith('.gif') || _isGiphyUrl(url) || lower.contains('tenor.com/');
}
// ============================================================
// 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,
);
// Auto-scroll only when a NEW message is added (not on any rebuild)
if (messages.length > _previousMessageCount && _previousMessageCount > 0) {
// Only scroll if user is near the bottom (not scrolled up reading history)
if (_scrollController.hasClients &&
_scrollController.position.pixels < 150) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _scrollToBottom(animated: true);
});
}
}
_previousMessageCount = messages.length;
final otherPartyName = conversation?.otherParty.name ?? 'Chat';
final otherPartyAvatar = conversation?.otherParty.avatar;
final isOnline = conversation?.otherParty.isOnline ?? false;
final otherPartyHeadline = conversation?.otherParty.headline;
return Column(
children: [
// Chat header with name + status
_buildHeader(
name: otherPartyName,
isOnline: isOnline,
avatar: otherPartyAvatar,
agentProfileId: conversation?.agentProfileId,
),
Expanded(
child: messagingState.isLoadingMessages && messages.isEmpty
? const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,
strokeWidth: 2,
),
)
: _buildChatBody(
messages: messages,
currentUserId: currentUserId ?? '',
otherPartyName: otherPartyName,
otherPartyAvatar: otherPartyAvatar,
otherPartyHeadline: otherPartyHeadline,
isOnline: isOnline,
isTyping: isTyping,
isLoadingMore: messagingState.isLoadingMessages &&
messages.isNotEmpty,
),
),
_buildInputArea(),
],
);
}
// ============================================================
// HEADER
// ============================================================
Widget _buildHeader({
required String name,
required bool isOnline,
String? avatar,
String? agentProfileId,
}) {
final initials = _getInitials(name);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
child: Row(
children: [
// Back arrow
GestureDetector(
onTap: _handleBack,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.only(right: 12),
child: Icon(
Icons.arrow_back,
size: 22,
color: AppColors.primaryDark,
),
),
),
// Avatar
Stack(
children: [
ClipOval(
child: (avatar != null && avatar.isNotEmpty)
? S3Image(
imageUrl: avatar,
width: 40,
height: 40,
fit: BoxFit.cover,
errorWidget: (_) =>
_AvatarFallback(letter: initials, size: 40),
)
: _AvatarFallback(letter: initials, size: 40),
),
if (isOnline)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: const Color(0xFF4CAF50),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
),
const SizedBox(width: 10),
// Name and active status
Expanded(
child: Column(
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 menu (matches web: Clear Chat, Mute, Report, Delete)
PopupMenuButton<String>(
icon: const Icon(
Icons.more_vert,
size: 20,
color: AppColors.primaryDark,
),
padding: EdgeInsets.zero,
onSelected: (value) => _handleMenuAction(value, agentProfileId),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'clear_chat',
child: Text('Clear Chat'),
),
PopupMenuItem(
value: 'mute',
child: Text(_isMuted ? 'Unmute Notifications' : 'Mute Notifications'),
),
const PopupMenuItem(
value: 'report',
child: Text('Report User'),
),
const PopupMenuItem(
value: 'delete',
child: Text(
'Delete Conversation',
style: TextStyle(color: Colors.red),
),
),
],
),
// Mute indicator
if (_isMuted)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.volume_off_rounded, size: 14, color: AppColors.primaryDark.withValues(alpha: 0.6)),
const SizedBox(width: 2),
Text('Muted', style: TextStyle(fontSize: 10, color: AppColors.primaryDark.withValues(alpha: 0.6), fontFamily: 'SourceSerif4')),
],
),
),
// Star icon
GestureDetector(
onTap: () {
final newStarred = !_isStarred;
setState(() => _isStarred = newStarred);
ref.read(messagingProvider.notifier).toggleFavorite(widget.conversationId, newStarred);
},
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(
_isStarred ? Icons.star_rounded : Icons.star_outline_rounded,
size: 24,
color: _isStarred ? AppColors.accentOrange : AppColors.primaryDark.withValues(alpha: 0.4),
),
),
),
],
),
);
}
// ============================================================
// CHAT BODY
// ============================================================
Widget _buildChatBody({
required List<ChatMessage> messages,
required String currentUserId,
required String otherPartyName,
String? otherPartyAvatar,
String? otherPartyHeadline,
required bool isOnline,
required bool isTyping,
required bool isLoadingMore,
}) {
if (messages.isEmpty) {
return SingleChildScrollView(
child: Column(
children: [
_buildProfileSection(
name: otherPartyName,
avatar: otherPartyAvatar,
headline: otherPartyHeadline,
isOnline: isOnline,
),
const SizedBox(height: 60),
Text(
'No messages yet.\nSay hello!',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.hintText,
),
),
],
),
);
}
return _buildMessagesList(
messages: messages,
currentUserId: currentUserId,
currentUserName: 'You',
otherPartyName: otherPartyName,
otherPartyAvatar: otherPartyAvatar,
otherPartyHeadline: otherPartyHeadline,
isOnline: isOnline,
isTyping: isTyping,
isLoadingMore: isLoadingMore,
);
}
// ============================================================
// PROFILE SECTION
// ============================================================
Widget _buildProfileSection({
required String name,
String? avatar,
String? headline,
required bool isOnline,
}) {
final initials = _getInitials(name);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Large avatar with online dot
Stack(
children: [
ClipOval(
child: (avatar != null && avatar.isNotEmpty)
? S3Image(
imageUrl: avatar,
width: 80,
height: 80,
fit: BoxFit.cover,
errorWidget: (_) =>
_AvatarFallback(letter: initials, size: 80),
)
: _AvatarFallback(letter: initials, size: 80),
),
if (isOnline)
Positioned(
right: 2,
bottom: 2,
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(height: 14),
// Name with pronouns and orange dot
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
name,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
const Text(
'(He/Him)',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 6),
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: AppColors.accentOrange,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 12),
// Specialties row
if (headline != null && headline.isNotEmpty)
_buildSpecialtiesRow(headline)
else
_buildSpecialtiesRow('Sales,Analytics,Inspection,Residential,Commercial'),
const SizedBox(height: 12),
// Divider
Divider(
color: AppColors.primaryDark.withValues(alpha: 0.1),
thickness: 0.5,
),
],
),
);
}
Widget _buildSpecialtiesRow(String headline) {
final specialties = headline.split(RegExp(r'[,|;]'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
if (specialties.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < specialties.length; i++) ...[
Text(
specialties[i],
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
if (i < specialties.length - 1)
const SizedBox(width: 16),
],
],
),
);
}
// ============================================================
// MESSAGES LIST
// ============================================================
Widget _buildMessagesList({
required List<ChatMessage> messages,
required String currentUserId,
required String currentUserName,
required String otherPartyName,
String? otherPartyAvatar,
String? otherPartyHeadline,
required bool isOnline,
required bool isTyping,
required bool isLoadingMore,
}) {
final itemCount =
messages.length + 1 + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0);
return ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
itemCount: itemCount,
itemBuilder: (context, index) {
if (isTyping && index == 0) {
return _buildTypingIndicator();
}
final adjustedIndex = isTyping ? index - 1 : index;
final profileIndex = messages.length + (isLoadingMore ? 1 : 0);
if (adjustedIndex == profileIndex) {
return _buildProfileSection(
name: otherPartyName,
avatar: otherPartyAvatar,
headline: otherPartyHeadline,
isOnline: isOnline,
);
}
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);
final senderName = isMine
? (message.sender.displayName.isNotEmpty
? message.sender.displayName
: currentUserName)
: otherPartyName;
final senderAvatar = isMine ? null : otherPartyAvatar;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: 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,
),
),
],
),
);
}
// ============================================================
// 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://');
final isGif = _isGifUrl(imageUrl) || message.mimeType == 'image/gif';
return GestureDetector(
onTap: () => _showImageFullScreen(context, imageUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 250,
maxHeight: 250,
),
child: isGif && isDirectUrl
// Use Image.network for GIFs to preserve animation
? Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _buildImagePlaceholder();
},
errorBuilder: (context, error, stack) => _buildImageError(),
)
: 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,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(),
Flexible(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Name row with timestamp and status
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatusIcon(message.status),
const SizedBox(width: 4),
Text(
timestamp,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w200,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 6),
Flexible(
child: Text(
senderName,
overflow: TextOverflow.ellipsis,
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 content
_buildMessageContent(message, isMine: true),
// Show text content below media if present (but not URLs/GIF links)
if (isMediaMessage &&
message.content.isNotEmpty &&
!message.content.trim().startsWith('http'))
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,
),
),
],
),
),
const SizedBox(width: 10),
// Avatar
_buildMessageAvatar(null, senderName, 45),
],
);
}
// ============================================================
// 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,
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(
children: [
Flexible(
child: Text(
senderName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
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 content
_buildMessageContent(message, isMine: false),
// Show text content below media if present (but not URLs/GIF links)
if (isMediaMessage &&
message.content.isNotEmpty &&
!message.content.trim().startsWith('http'))
Padding(
padding: const EdgeInsets.only(top: 4),
child: 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 initials = _getInitials(name);
return ClipOval(
child: (avatarKey != null && avatarKey.isNotEmpty)
? S3Image(
imageUrl: avatarKey,
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(left: 24, bottom: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_BouncingDot(delay: 0),
const SizedBox(width: 4),
_BouncingDot(delay: 150),
const SizedBox(width: 4),
_BouncingDot(delay: 300),
const SizedBox(width: 8),
Text(
_formatTime(DateTime.now().toIso8601String()),
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
],
),
),
),
);
}
// ============================================================
// IMAGE FULL SCREEN VIEWER
// ============================================================
void _showImageFullScreen(BuildContext context, String imageUrl) {
final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
Navigator.of(context, rootNavigator: true).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() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
color: Colors.white,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Plus icon
GestureDetector(
onTap: _showAttachmentOptions,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(
Icons.add,
size: 24,
color: AppColors.primaryDark,
),
),
),
// Text input pill with send icon inside
Expanded(
child: SizedBox(
height: 44,
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: InputDecoration(
hintText: 'Write a Message',
hintStyle: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(
color: AppColors.primaryDark,
width: 1.5,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(
color: AppColors.primaryDark,
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(
color: AppColors.primaryDark,
width: 1.5,
),
),
suffixIcon: GestureDetector(
onTap: _sendMessage,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(
Icons.send,
size: 20,
color: AppColors.accentOrange,
),
),
),
suffixIconConstraints: const BoxConstraints(
minWidth: 40,
minHeight: 20,
),
),
),
),
),
const SizedBox(width: 10),
// Mic icon in filled dark circle
GestureDetector(
onTap: () => _showComingSoon('Speech to text'),
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: AppColors.primaryDark,
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
Icons.mic_none,
size: 20,
color: Colors.white,
),
),
),
),
],
),
);
}
// ============================================================
// 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,
),
),
);
}
}
/// Bouncing dot animation matching web's animate-bounce with staggered delay.
class _BouncingDot extends StatefulWidget {
final int delay;
const _BouncingDot({required this.delay});
@override
State<_BouncingDot> createState() => _BouncingDotState();
}
class _BouncingDotState extends State<_BouncingDot>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_animation = Tween<double>(begin: 0, end: -6).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
Future.delayed(Duration(milliseconds: widget.delay), () {
if (mounted) _controller.repeat(reverse: true);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _animation.value),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
),
);
},
);
}
}