2026-03-08 00:41:44 +05:30
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
2026-03-08 02:01:03 +05:30
|
|
|
import 'package:go_router/go_router.dart';
|
2026-03-08 00:41:44 +05:30
|
|
|
import 'package:real_estate_mobile/config/app_config.dart';
|
|
|
|
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
2026-03-08 12:15:15 +05:30
|
|
|
import 'package:real_estate_mobile/core/utils/image_url_resolver.dart';
|
|
|
|
|
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
|
2026-03-08 00:41:44 +05:30
|
|
|
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';
|
2026-03-08 12:15:15 +05:30
|
|
|
import 'package:url_launcher/url_launcher.dart';
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
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);
|
2026-03-08 02:01:03 +05:30
|
|
|
context.go('/messages');
|
2026-03-08 00:41:44 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// DATE / TIME FORMATTING
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
String _formatTime(String isoTimestamp) {
|
|
|
|
|
try {
|
|
|
|
|
final dt = DateTime.parse(isoTimestamp).toLocal();
|
2026-03-08 12:15:15 +05:30
|
|
|
final hour = dt.hour > 12
|
|
|
|
|
? dt.hour - 12
|
|
|
|
|
: (dt.hour == 0 ? 12 : dt.hour);
|
2026-03-08 00:41:44 +05:30
|
|
|
final minute = dt.minute.toString().padLeft(2, '0');
|
2026-03-08 12:15:15 +05:30
|
|
|
final amPm = dt.hour >= 12 ? 'PM' : 'AM';
|
|
|
|
|
return '$hour:$minute $amPm';
|
2026-03-08 00:41:44 +05:30
|
|
|
} 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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
// ============================================================
|
|
|
|
|
// 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;
|
2026-03-08 02:01:03 +05:30
|
|
|
final otherPartyHeadline = conversation?.otherParty.headline;
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
children: [
|
|
|
|
|
// Chat header with name + status
|
|
|
|
|
_buildHeader(
|
|
|
|
|
name: otherPartyName,
|
|
|
|
|
isOnline: isOnline,
|
|
|
|
|
),
|
|
|
|
|
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(),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-03-08 12:15:15 +05:30
|
|
|
// HEADER
|
2026-03-08 02:01:03 +05:30
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
Widget _buildHeader({
|
|
|
|
|
required String name,
|
|
|
|
|
required bool isOnline,
|
|
|
|
|
}) {
|
|
|
|
|
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: 16),
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.arrow_back,
|
|
|
|
|
size: 22,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 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 (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: 4),
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.star_rounded,
|
|
|
|
|
size: 24,
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-03-08 12:15:15 +05:30
|
|
|
// CHAT BODY
|
2026-03-08 02:01:03 +05:30
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
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(
|
2026-03-08 00:41:44 +05:30
|
|
|
child: Column(
|
|
|
|
|
children: [
|
2026-03-08 02:01:03 +05:30
|
|
|
_buildProfileSection(
|
2026-03-08 00:41:44 +05:30
|
|
|
name: otherPartyName,
|
2026-03-08 02:01:03 +05:30
|
|
|
avatar: otherPartyAvatar,
|
|
|
|
|
headline: otherPartyHeadline,
|
2026-03-08 00:41:44 +05:30
|
|
|
isOnline: isOnline,
|
|
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
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,
|
|
|
|
|
),
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return _buildMessagesList(
|
|
|
|
|
messages: messages,
|
|
|
|
|
currentUserId: currentUserId,
|
|
|
|
|
currentUserName: 'You',
|
|
|
|
|
otherPartyName: otherPartyName,
|
|
|
|
|
otherPartyAvatar: otherPartyAvatar,
|
|
|
|
|
otherPartyHeadline: otherPartyHeadline,
|
|
|
|
|
isOnline: isOnline,
|
|
|
|
|
isTyping: isTyping,
|
|
|
|
|
isLoadingMore: isLoadingMore,
|
2026-03-08 00:41:44 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-03-08 12:15:15 +05:30
|
|
|
// PROFILE SECTION
|
2026-03-08 00:41:44 +05:30
|
|
|
// ============================================================
|
|
|
|
|
|
2026-03-08 02:01:03 +05:30
|
|
|
Widget _buildProfileSection({
|
2026-03-08 00:41:44 +05:30
|
|
|
required String name,
|
2026-03-08 02:01:03 +05:30
|
|
|
String? avatar,
|
|
|
|
|
String? headline,
|
2026-03-08 00:41:44 +05:30
|
|
|
required bool isOnline,
|
|
|
|
|
}) {
|
2026-03-08 02:01:03 +05:30
|
|
|
final avatarUrl = _resolveAvatarUrl(avatar);
|
|
|
|
|
final initials = _getInitials(name);
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
return Padding(
|
2026-03-08 02:01:03 +05:30
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 16),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
// Large avatar with online dot
|
|
|
|
|
Stack(
|
|
|
|
|
children: [
|
|
|
|
|
ClipOval(
|
|
|
|
|
child: avatarUrl.isNotEmpty
|
|
|
|
|
? CachedNetworkImage(
|
|
|
|
|
imageUrl: avatarUrl,
|
|
|
|
|
width: 80,
|
|
|
|
|
height: 80,
|
|
|
|
|
fit: BoxFit.cover,
|
2026-03-08 12:15:15 +05:30
|
|
|
errorWidget: (_, e, s) =>
|
2026-03-08 02:01:03 +05:30
|
|
|
_AvatarFallback(letter: initials, size: 80),
|
|
|
|
|
)
|
|
|
|
|
: _AvatarFallback(letter: initials, size: 80),
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
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),
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
|
// Name with pronouns and orange dot
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
name,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
const Text(
|
|
|
|
|
'(He/Him)',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
2026-03-08 00:41:44 +05:30
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
Container(
|
|
|
|
|
width: 10,
|
|
|
|
|
height: 10,
|
|
|
|
|
decoration: const BoxDecoration(
|
2026-03-08 00:41:44 +05:30
|
|
|
color: AppColors.accentOrange,
|
2026-03-08 02:01:03 +05:30
|
|
|
shape: BoxShape.circle,
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
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,
|
|
|
|
|
),
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
if (i < specialties.length - 1)
|
|
|
|
|
const SizedBox(width: 16),
|
2026-03-08 00:41:44 +05:30
|
|
|
],
|
2026-03-08 02:01:03 +05:30
|
|
|
],
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// MESSAGES LIST
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
Widget _buildMessagesList({
|
|
|
|
|
required List<ChatMessage> messages,
|
|
|
|
|
required String currentUserId,
|
|
|
|
|
required String currentUserName,
|
|
|
|
|
required String otherPartyName,
|
|
|
|
|
String? otherPartyAvatar,
|
2026-03-08 02:01:03 +05:30
|
|
|
String? otherPartyHeadline,
|
|
|
|
|
required bool isOnline,
|
2026-03-08 00:41:44 +05:30
|
|
|
required bool isTyping,
|
|
|
|
|
required bool isLoadingMore,
|
|
|
|
|
}) {
|
|
|
|
|
final itemCount =
|
2026-03-08 02:01:03 +05:30
|
|
|
messages.length + 1 + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0);
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
return ListView.builder(
|
|
|
|
|
controller: _scrollController,
|
|
|
|
|
reverse: true,
|
2026-03-08 02:01:03 +05:30
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
2026-03-08 00:41:44 +05:30
|
|
|
itemCount: itemCount,
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
if (isTyping && index == 0) {
|
|
|
|
|
return _buildTypingIndicator();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final adjustedIndex = isTyping ? index - 1 : index;
|
|
|
|
|
|
2026-03-08 02:01:03 +05:30
|
|
|
final profileIndex = messages.length + (isLoadingMore ? 1 : 0);
|
|
|
|
|
if (adjustedIndex == profileIndex) {
|
|
|
|
|
return _buildProfileSection(
|
|
|
|
|
name: otherPartyName,
|
|
|
|
|
avatar: otherPartyAvatar,
|
|
|
|
|
headline: otherPartyHeadline,
|
|
|
|
|
isOnline: isOnline,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
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;
|
|
|
|
|
|
2026-03-08 02:01:03 +05:30
|
|
|
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),
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
],
|
|
|
|
|
),
|
2026-03-08 00:41:44 +05:30
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- 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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
// ============================================================
|
|
|
|
|
// 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)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
Widget _buildSentMessage(ChatMessage message, String senderName) {
|
|
|
|
|
final timestamp = _formatTime(message.createdAt);
|
2026-03-08 12:15:15 +05:30
|
|
|
final isMediaMessage = message.messageType == MessageType.image ||
|
|
|
|
|
message.messageType == MessageType.file;
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
return Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
const Spacer(),
|
|
|
|
|
Flexible(
|
|
|
|
|
flex: 3,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
|
|
children: [
|
2026-03-08 12:15:15 +05:30
|
|
|
// Name row with timestamp and status
|
2026-03-08 00:41:44 +05:30
|
|
|
Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
2026-03-08 12:15:15 +05:30
|
|
|
_buildStatusIcon(message.status),
|
|
|
|
|
const SizedBox(width: 4),
|
2026-03-08 00:41:44 +05:30
|
|
|
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),
|
2026-03-08 02:01:03 +05:30
|
|
|
const Text(
|
|
|
|
|
'He/Him',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 6),
|
2026-03-08 00:41:44 +05:30
|
|
|
Container(
|
|
|
|
|
width: 8,
|
|
|
|
|
height: 8,
|
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
2026-03-08 12:15:15 +05:30
|
|
|
// 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,
|
|
|
|
|
),
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
// Avatar
|
|
|
|
|
_buildMessageAvatar(null, senderName, 45),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
// ============================================================
|
|
|
|
|
// RECEIVED MESSAGE (left-aligned)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
Widget _buildReceivedMessage(
|
|
|
|
|
ChatMessage message, String senderName, String? avatar) {
|
|
|
|
|
final timestamp = _formatTime(message.createdAt);
|
2026-03-08 12:15:15 +05:30
|
|
|
final isMediaMessage = message.messageType == MessageType.image ||
|
|
|
|
|
message.messageType == MessageType.file;
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
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),
|
2026-03-08 02:01:03 +05:30
|
|
|
const Text(
|
|
|
|
|
'He/Him',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 6),
|
2026-03-08 00:41:44 +05:30
|
|
|
Text(
|
|
|
|
|
timestamp,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontWeight: FontWeight.w200,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
2026-03-08 12:15:15 +05:30
|
|
|
// 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,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
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,
|
2026-03-08 12:15:15 +05:30
|
|
|
errorWidget: (_, e, s) =>
|
2026-03-08 00:41:44 +05:30
|
|
|
_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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-03-08 12:15:15 +05:30
|
|
|
// 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
|
2026-03-08 00:41:44 +05:30
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
Widget _buildInputArea() {
|
|
|
|
|
return Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
children: [
|
2026-03-08 12:15:15 +05:30
|
|
|
// Plus icon
|
2026-03-08 00:41:44 +05:30
|
|
|
GestureDetector(
|
|
|
|
|
onTap: _showAttachmentOptions,
|
2026-03-08 02:01:03 +05:30
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
child: const Padding(
|
|
|
|
|
padding: EdgeInsets.only(right: 10),
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.add,
|
|
|
|
|
size: 24,
|
|
|
|
|
color: AppColors.primaryDark,
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
|
2026-03-08 02:01:03 +05:30
|
|
|
// Text input pill with send icon inside
|
2026-03-08 00:41:44 +05:30
|
|
|
Expanded(
|
|
|
|
|
child: Container(
|
2026-03-08 12:15:15 +05:30
|
|
|
height: 44,
|
|
|
|
|
margin: const EdgeInsets.only(left: 4),
|
2026-03-08 00:41:44 +05:30
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.white,
|
2026-03-08 12:15:15 +05:30
|
|
|
borderRadius: BorderRadius.circular(22),
|
2026-03-08 00:41:44 +05:30
|
|
|
border: Border.all(
|
|
|
|
|
color: AppColors.primaryDark,
|
2026-03-08 02:01:03 +05:30
|
|
|
width: 1.5,
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
clipBehavior: Clip.antiAlias,
|
2026-03-08 00:41:44 +05:30
|
|
|
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(
|
2026-03-08 12:15:15 +05:30
|
|
|
horizontal: 18,
|
|
|
|
|
vertical: 10,
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
border: InputBorder.none,
|
|
|
|
|
enabledBorder: InputBorder.none,
|
|
|
|
|
focusedBorder: InputBorder.none,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-03-08 02:01:03 +05:30
|
|
|
// Send arrow inside the pill
|
2026-03-08 00:41:44 +05:30
|
|
|
GestureDetector(
|
|
|
|
|
onTap: _sendMessage,
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
child: Padding(
|
2026-03-08 12:15:15 +05:30
|
|
|
padding: const EdgeInsets.only(right: 14),
|
2026-03-08 00:41:44 +05:30
|
|
|
child: Icon(
|
|
|
|
|
Icons.send,
|
|
|
|
|
size: 20,
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
|
2026-03-08 02:01:03 +05:30
|
|
|
// Mic icon in filled dark circle
|
2026-03-08 00:41:44 +05:30
|
|
|
GestureDetector(
|
|
|
|
|
onTap: () => _showComingSoon('Speech to text'),
|
|
|
|
|
child: Container(
|
|
|
|
|
width: 36,
|
|
|
|
|
height: 36,
|
2026-03-08 02:01:03 +05:30
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
color: AppColors.primaryDark,
|
2026-03-08 00:41:44 +05:30
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
),
|
|
|
|
|
child: const Center(
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.mic_none,
|
|
|
|
|
size: 20,
|
2026-03-08 02:01:03 +05:30
|
|
|
color: Colors.white,
|
2026-03-08 00:41:44 +05:30
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 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)),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-08 02:01:03 +05:30
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- 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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|