feat: Implement real-time messaging with socket listeners and enable starting chats from agent details.

This commit is contained in:
pradeepkumar
2026-03-15 00:28:04 +05:30
parent fed89cbef2
commit fe2bc5bf28
4 changed files with 273 additions and 58 deletions

View File

@@ -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<AgentDetailScreen> {
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,
),
),
],
),
),
);
}

View File

@@ -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 {

View File

@@ -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<MessagingState> {
final MessagingRepository _repository;
final Ref _ref;
final SocketService _socket = SocketService();
final List<StreamSubscription> _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<ChatMessage>.from(state.messages[convId] ?? []);
// Avoid duplicates
if (currentMessages.any((m) => m.id == message.id)) return;
currentMessages.insert(0, message);
final updatedMessages =
Map<String, List<ChatMessage>>.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<String>.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<void> loadConversations() async {
state = state.copyWith(isLoadingConversations: true, clearError: true);
try {
@@ -264,11 +376,14 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
}
Future<String?> 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<MessagingState> {
);
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;
}
}

View File

@@ -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<ChatScreen> {
bool _isComposing = false;
int _previousMessageCount = 0;
final SocketService _socket = SocketService();
@override
void initState() {
super.initState();
@@ -34,6 +37,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
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<ChatScreen> {
@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<ChatScreen> {
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<ChatScreen> {
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<ChatScreen> {
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),