diff --git a/lib/features/agents/presentation/screens/agent_detail_screen.dart b/lib/features/agents/presentation/screens/agent_detail_screen.dart index a1d9c2e..99c1f4a 100644 --- a/lib/features/agents/presentation/screens/agent_detail_screen.dart +++ b/lib/features/agents/presentation/screens/agent_detail_screen.dart @@ -7,6 +7,7 @@ import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart'; import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.dart'; import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart'; class AgentDetailScreen extends ConsumerStatefulWidget { final String agentId; @@ -203,55 +204,121 @@ class _AgentDetailScreenState extends ConsumerState { return Padding( padding: const EdgeInsets.symmetric(horizontal: 38), - child: Container( - height: 50, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.1), - width: 1, - ), - ), - child: Row( - children: [ - const SizedBox(width: 16), - // Status dot - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isAvailable - ? const Color(0xFF22C55E) - : Colors.white, - border: Border.all( - color: isAvailable - ? const Color(0xFF22C55E) - : AppColors.primaryDark.withValues(alpha: 0.3), - width: 1.5, + child: Column( + children: [ + Container( + height: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Row( + children: [ + const SizedBox(width: 16), + // Status dot + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isAvailable + ? const Color(0xFF22C55E) + : Colors.white, + border: Border.all( + color: isAvailable + ? const Color(0xFF22C55E) + : AppColors.primaryDark.withValues(alpha: 0.3), + width: 1.5, + ), + ), ), - ), + const SizedBox(width: 8), + Text( + isAvailable ? 'Available.' : 'Unavailable.', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const Spacer(), + // Connect/Pending/Unlink button + _buildConnectButton( + connState, + isAvailable, + () => _handleConnect(state), + ), + const SizedBox(width: 4), + ], ), - const SizedBox(width: 8), - Text( - isAvailable ? 'Available.' : 'Unavailable.', - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const Spacer(), - // Connect/Pending/Unlink button - _buildConnectButton( - connState, - isAvailable, - () => _handleConnect(state), - ), - const SizedBox(width: 4), + ), + // Start Message button when connected + if (connState == 'accepted') ...[ + const SizedBox(height: 12), + _buildStartMessageButton(agent), ], + ], + ), + ); + } + + bool _isStartingConversation = false; + + void _handleStartMessage(AgentProfile agent) async { + if (_isStartingConversation) return; + setState(() => _isStartingConversation = true); + // Capture notifier before async gap to avoid using ref after dispose + final notifier = ref.read(messagingProvider.notifier); + try { + final conversationId = await notifier.startConversation(agent.id); + if (!mounted) return; + if (conversationId != null) { + context.push('/messages/chat/$conversationId'); + } + } finally { + if (mounted) setState(() => _isStartingConversation = false); + } + } + + Widget _buildStartMessageButton(AgentProfile agent) { + return GestureDetector( + onTap: _isStartingConversation ? null : () => _handleStartMessage(agent), + child: Container( + height: 46, + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(15), ), + alignment: Alignment.center, + child: _isStartingConversation + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.chat_bubble_outline, size: 18, color: Colors.white), + SizedBox(width: 8), + Text( + 'Start Message', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), ), ); } diff --git a/lib/features/messaging/data/models/messaging_models.dart b/lib/features/messaging/data/models/messaging_models.dart index 5c9fa34..85ddf40 100644 --- a/lib/features/messaging/data/models/messaging_models.dart +++ b/lib/features/messaging/data/models/messaging_models.dart @@ -67,6 +67,18 @@ class OtherParty { lastSeenAt: json['lastSeenAt'] as String?, ); } + + OtherParty copyWith({bool? isOnline, String? lastSeenAt}) { + return OtherParty( + id: id, + userId: userId, + name: name, + avatar: avatar, + headline: headline, + isOnline: isOnline ?? this.isOnline, + lastSeenAt: lastSeenAt ?? this.lastSeenAt, + ); + } } class Conversation { diff --git a/lib/features/messaging/presentation/providers/messaging_provider.dart b/lib/features/messaging/presentation/providers/messaging_provider.dart index ada7f09..174bb6d 100644 --- a/lib/features/messaging/presentation/providers/messaging_provider.dart +++ b/lib/features/messaging/presentation/providers/messaging_provider.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart'; import 'package:real_estate_mobile/features/messaging/data/messaging_repository.dart'; +import 'package:real_estate_mobile/features/messaging/data/socket_service.dart'; final _log = Logger(printer: PrettyPrinter(methodCount: 0)); @@ -66,12 +69,121 @@ class MessagingState { class MessagingNotifier extends StateNotifier { final MessagingRepository _repository; final Ref _ref; + final SocketService _socket = SocketService(); + final List _subscriptions = []; MessagingNotifier(this._repository, this._ref) - : super(const MessagingState()); + : super(const MessagingState()) { + _initSocketListeners(); + } String? get _currentUserId => _ref.read(authProvider).user?.id; + void _initSocketListeners() { + // Online/offline status changes + _subscriptions.add(_socket.onStatusChange.listen((data) { + final userId = data['userId'] as String?; + final isOnline = data['isOnline'] as bool? ?? false; + final lastSeenAt = data['lastSeenAt'] as String?; + if (userId != null) { + updateUserStatus(userId, isOnline, lastSeenAt: lastSeenAt); + } + })); + + // Incoming messages from other users + _subscriptions.add(_socket.onNewMessage.listen((message) { + if (!mounted) return; + final convId = message.conversationId; + final currentMessages = + List.from(state.messages[convId] ?? []); + + // Avoid duplicates + if (currentMessages.any((m) => m.id == message.id)) return; + + currentMessages.insert(0, message); + final updatedMessages = + Map>.from(state.messages) + ..[convId] = currentMessages; + + // Update conversation's last message and unread count + final updatedConversations = state.conversations.map((c) { + if (c.id == convId) { + final isActive = state.activeConversationId == convId; + return c.copyWith( + lastMessageText: message.content, + lastMessageAt: message.createdAt, + unreadCount: isActive ? c.unreadCount : c.unreadCount + 1, + ); + } + return c; + }).toList(); + + state = state.copyWith( + messages: updatedMessages, + conversations: updatedConversations, + ); + })); + + // Typing indicators + _subscriptions.add(_socket.onTypingStart.listen((data) { + if (!mounted) return; + final userId = data['userId'] as String?; + if (userId != null && userId != _currentUserId) { + state = state.copyWith( + typingUsers: {...state.typingUsers, userId}, + ); + } + })); + + _subscriptions.add(_socket.onTypingStop.listen((data) { + if (!mounted) return; + final userId = data['userId'] as String?; + if (userId != null) { + final updated = Set.from(state.typingUsers)..remove(userId); + state = state.copyWith(typingUsers: updated); + } + })); + + // Read receipts + _subscriptions.add(_socket.onMessagesRead.listen((data) { + if (!mounted) return; + final convId = data['conversationId'] as String?; + if (convId == null) return; + final updatedConversations = state.conversations.map((c) { + if (c.id == convId) return c.copyWith(unreadCount: 0); + return c; + }).toList(); + state = state.copyWith(conversations: updatedConversations); + })); + } + + /// Update online status for a user across all conversations (matching web) + void updateUserStatus(String userId, bool isOnline, {String? lastSeenAt}) { + if (!mounted) return; + final updatedConversations = state.conversations.map((c) { + if (c.otherParty.userId == userId || c.otherParty.id == userId) { + return c.copyWith( + otherParty: c.otherParty.copyWith( + isOnline: isOnline, + lastSeenAt: lastSeenAt, + ), + ); + } + return c; + }).toList(); + + state = state.copyWith(conversations: updatedConversations); + } + + @override + void dispose() { + for (final sub in _subscriptions) { + sub.cancel(); + } + _subscriptions.clear(); + super.dispose(); + } + Future loadConversations() async { state = state.copyWith(isLoadingConversations: true, clearError: true); try { @@ -264,11 +376,14 @@ class MessagingNotifier extends StateNotifier { } Future startConversation(String agentProfileId) async { + if (!mounted) return null; state = state.copyWith(isLoadingConversations: true, clearError: true); try { final conversation = await _repository.startConversation(agentProfileId); + if (!mounted) return conversation.id; + final exists = state.conversations.any((c) => c.id == conversation.id); final updatedConversations = exists ? state.conversations @@ -280,10 +395,12 @@ class MessagingNotifier extends StateNotifier { ); return conversation.id; } catch (_) { - state = state.copyWith( - isLoadingConversations: false, - error: 'Failed to start conversation', - ); + if (mounted) { + state = state.copyWith( + isLoadingConversations: false, + error: 'Failed to start conversation', + ); + } return null; } } diff --git a/lib/features/messaging/presentation/screens/chat_screen.dart b/lib/features/messaging/presentation/screens/chat_screen.dart index ab98e47..759c6d2 100644 --- a/lib/features/messaging/presentation/screens/chat_screen.dart +++ b/lib/features/messaging/presentation/screens/chat_screen.dart @@ -7,6 +7,7 @@ import 'package:real_estate_mobile/core/utils/image_url_resolver.dart'; import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart'; +import 'package:real_estate_mobile/features/messaging/data/socket_service.dart'; import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -26,6 +27,8 @@ class _ChatScreenState extends ConsumerState { bool _isComposing = false; int _previousMessageCount = 0; + final SocketService _socket = SocketService(); + @override void initState() { super.initState(); @@ -34,6 +37,8 @@ class _ChatScreenState extends ConsumerState { notifier.setActiveConversation(widget.conversationId); notifier.loadMessages(widget.conversationId); notifier.markAsRead(widget.conversationId); + // Join socket room for real-time updates + _socket.joinConversation(widget.conversationId); }); _scrollController.addListener(_onScroll); _messageController.addListener(_onTextChanged); @@ -41,6 +46,11 @@ class _ChatScreenState extends ConsumerState { @override void dispose() { + // Stop typing and leave room before disposing + if (_isComposing) { + _socket.stopTyping(widget.conversationId); + } + _socket.leaveConversation(widget.conversationId); ref.read(messagingProvider.notifier).setActiveConversation(null); _messageController.dispose(); _scrollController.dispose(); @@ -61,6 +71,12 @@ class _ChatScreenState extends ConsumerState { 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); + } } } @@ -81,6 +97,7 @@ class _ChatScreenState extends ConsumerState { final text = _messageController.text.trim(); if (text.isEmpty) return; + _socket.stopTyping(widget.conversationId); ref .read(messagingProvider.notifier) .sendMessage(widget.conversationId, text); @@ -998,15 +1015,17 @@ class _ChatScreenState extends ConsumerState { 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, + Flexible( + child: Text( + senderName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 6),