1910 lines
61 KiB
Dart
1910 lines
61 KiB
Dart
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:dio/dio.dart';
|
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
|
import 'package:real_estate_mobile/core/network/api_client.dart';
|
|
import 'package:real_estate_mobile/core/network/api_exceptions.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:speech_to_text/speech_to_text.dart' as stt;
|
|
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;
|
|
|
|
// Speech-to-text
|
|
final stt.SpeechToText _speech = stt.SpeechToText();
|
|
bool _speechAvailable = false;
|
|
bool _isListening = false;
|
|
String _lastPartial = '';
|
|
|
|
@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);
|
|
// Initialize mute/star state from conversation data
|
|
final conversations = ref.read(messagingProvider).conversations;
|
|
final conv = conversations.where((c) => c.id == widget.conversationId).firstOrNull;
|
|
// Only mark as read when (a) there are actually unread messages and
|
|
// (b) the user stays on the chat long enough to be considered "viewing"
|
|
// it. If they accidentally tap a tile and bounce back, `mounted` will
|
|
// be false and we skip the read receipt — avoiding false blue ticks.
|
|
final hasUnread = (conv?.unreadCount ?? 0) > 0;
|
|
if (hasUnread) {
|
|
Future.delayed(const Duration(milliseconds: 600), () {
|
|
if (mounted) {
|
|
notifier.markAsRead(widget.conversationId);
|
|
}
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
});
|
|
// Initialize speech-to-text (requests mic permission lazily on first use)
|
|
_initSpeech();
|
|
});
|
|
_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 deactivate() {
|
|
// Clear active conversation BEFORE the widget tree is disposed so the
|
|
// messaging_provider's onNewMessage listener immediately stops
|
|
// auto-marking incoming messages as read for this chat. dispose() runs
|
|
// too late and used a microtask which created a race window.
|
|
try {
|
|
ref.read(messagingProvider.notifier).setActiveConversation(null);
|
|
} catch (_) {}
|
|
PushNotificationService().activeConversationId = null;
|
|
super.deactivate();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_isListening) {
|
|
_speech.cancel();
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _initSpeech() async {
|
|
try {
|
|
_speechAvailable = await _speech.initialize(
|
|
onStatus: (status) {
|
|
// Stop UI listening indicator when engine stops on its own
|
|
if (status == 'done' || status == 'notListening') {
|
|
if (mounted && _isListening) {
|
|
setState(() => _isListening = false);
|
|
}
|
|
}
|
|
},
|
|
onError: (error) {
|
|
if (mounted) {
|
|
setState(() => _isListening = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Speech error: ${error.errorMsg}'),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
} catch (_) {
|
|
_speechAvailable = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleSpeech() async {
|
|
if (_isListening) {
|
|
await _speech.stop();
|
|
if (mounted) setState(() => _isListening = false);
|
|
return;
|
|
}
|
|
|
|
if (!_speechAvailable) {
|
|
await _initSpeech();
|
|
if (!_speechAvailable) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Microphone permission is required for speech input',
|
|
),
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Remember what was already typed so partials don't wipe it
|
|
_lastPartial = '';
|
|
final baseText = _messageController.text;
|
|
|
|
setState(() => _isListening = true);
|
|
await _speech.listen(
|
|
onResult: (result) {
|
|
final recognized = result.recognizedWords;
|
|
final separator =
|
|
baseText.isNotEmpty && !baseText.endsWith(' ') ? ' ' : '';
|
|
final newText = '$baseText$separator$recognized';
|
|
_messageController.value = TextEditingValue(
|
|
text: newText,
|
|
selection: TextSelection.collapsed(offset: newText.length),
|
|
);
|
|
_lastPartial = recognized;
|
|
},
|
|
listenFor: const Duration(seconds: 30),
|
|
pauseFor: const Duration(seconds: 3),
|
|
listenOptions: stt.SpeechListenOptions(
|
|
cancelOnError: true,
|
|
partialResults: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
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 descController = TextEditingController();
|
|
String selectedReason = 'Spam';
|
|
final reasons = [
|
|
'Spam',
|
|
'Harassment',
|
|
'Inappropriate Content',
|
|
'Scam / Fraud',
|
|
'Other',
|
|
];
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (ctx, setDialogState) => AlertDialog(
|
|
title: const Text('Report User'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Reason:', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
DropdownButtonFormField<String>(
|
|
value: selectedReason,
|
|
items: reasons.map((r) => DropdownMenuItem(value: r, child: Text(r))).toList(),
|
|
onChanged: (v) {
|
|
if (v != null) setDialogState(() => selectedReason = v);
|
|
},
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: descController,
|
|
maxLines: 3,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Additional details (optional)...',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(ctx);
|
|
await _submitReport(selectedReason, descController.text.trim());
|
|
},
|
|
child: const Text('Submit'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _submitReport(String reason, String description) async {
|
|
// Find the other party's userId for the report
|
|
final conversations = ref.read(messagingProvider).conversations;
|
|
final conv = conversations.where((c) => c.id == widget.conversationId).firstOrNull;
|
|
if (conv == null) return;
|
|
|
|
// otherParty.userId is the actual user ID (for agents),
|
|
// for regular users otherParty.id is the agentProfileId — we need the userId behind it
|
|
final reportedUserId = conv.otherParty.userId ?? conv.otherParty.id;
|
|
|
|
try {
|
|
final dio = ApiClient.instance.dio;
|
|
await dio.post('/user-reports', data: {
|
|
'reportedUserId': reportedUserId,
|
|
'conversationId': widget.conversationId,
|
|
'reason': reason,
|
|
if (description.isNotEmpty) 'description': description,
|
|
});
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Report submitted. Thank you.')),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
final msg = (e is DioException && e.error is ApiException)
|
|
? (e.error as ApiException).message
|
|
: 'Failed to submit report';
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(msg)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleBack() {
|
|
// deactivate() will clear activeConversationId — no need to do it here.
|
|
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 circle — turns orange while listening
|
|
GestureDetector(
|
|
onTap: _toggleSpeech,
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: _isListening
|
|
? AppColors.accentOrange
|
|
: AppColors.primaryDark,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Center(
|
|
child: Icon(
|
|
_isListening ? Icons.mic : 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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|