feat: Implement real-time messaging with socket listeners and enable starting chats from agent details.
This commit is contained in:
@@ -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/data/models/agent_profile.dart';
|
||||||
import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.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/auth/presentation/providers/auth_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
|
||||||
|
|
||||||
class AgentDetailScreen extends ConsumerStatefulWidget {
|
class AgentDetailScreen extends ConsumerStatefulWidget {
|
||||||
final String agentId;
|
final String agentId;
|
||||||
@@ -203,55 +204,121 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 38),
|
padding: const EdgeInsets.symmetric(horizontal: 38),
|
||||||
child: Container(
|
child: Column(
|
||||||
height: 50,
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
borderRadius: BorderRadius.circular(15),
|
height: 50,
|
||||||
border: Border.all(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
borderRadius: BorderRadius.circular(15),
|
||||||
width: 1,
|
border: Border.all(
|
||||||
),
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||||
),
|
width: 1,
|
||||||
child: Row(
|
),
|
||||||
children: [
|
),
|
||||||
const SizedBox(width: 16),
|
child: Row(
|
||||||
// Status dot
|
children: [
|
||||||
Container(
|
const SizedBox(width: 16),
|
||||||
width: 12,
|
// Status dot
|
||||||
height: 12,
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: 12,
|
||||||
shape: BoxShape.circle,
|
height: 12,
|
||||||
color: isAvailable
|
decoration: BoxDecoration(
|
||||||
? const Color(0xFF22C55E)
|
shape: BoxShape.circle,
|
||||||
: Colors.white,
|
color: isAvailable
|
||||||
border: Border.all(
|
? const Color(0xFF22C55E)
|
||||||
color: isAvailable
|
: Colors.white,
|
||||||
? const Color(0xFF22C55E)
|
border: Border.all(
|
||||||
: AppColors.primaryDark.withValues(alpha: 0.3),
|
color: isAvailable
|
||||||
width: 1.5,
|
? 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(
|
// Start Message button when connected
|
||||||
isAvailable ? 'Available.' : 'Unavailable.',
|
if (connState == 'accepted') ...[
|
||||||
style: const TextStyle(
|
const SizedBox(height: 12),
|
||||||
fontFamily: 'Fractul',
|
_buildStartMessageButton(agent),
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.primaryDark,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
// Connect/Pending/Unlink button
|
|
||||||
_buildConnectButton(
|
|
||||||
connState,
|
|
||||||
isAvailable,
|
|
||||||
() => _handleConnect(state),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,18 @@ class OtherParty {
|
|||||||
lastSeenAt: json['lastSeenAt'] as String?,
|
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 {
|
class Conversation {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.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/models/messaging_models.dart';
|
||||||
import 'package:real_estate_mobile/features/messaging/data/messaging_repository.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));
|
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||||
|
|
||||||
@@ -66,12 +69,121 @@ class MessagingState {
|
|||||||
class MessagingNotifier extends StateNotifier<MessagingState> {
|
class MessagingNotifier extends StateNotifier<MessagingState> {
|
||||||
final MessagingRepository _repository;
|
final MessagingRepository _repository;
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
final SocketService _socket = SocketService();
|
||||||
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
|
|
||||||
MessagingNotifier(this._repository, this._ref)
|
MessagingNotifier(this._repository, this._ref)
|
||||||
: super(const MessagingState());
|
: super(const MessagingState()) {
|
||||||
|
_initSocketListeners();
|
||||||
|
}
|
||||||
|
|
||||||
String? get _currentUserId => _ref.read(authProvider).user?.id;
|
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 {
|
Future<void> loadConversations() async {
|
||||||
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
||||||
try {
|
try {
|
||||||
@@ -264,11 +376,14 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> startConversation(String agentProfileId) async {
|
Future<String?> startConversation(String agentProfileId) async {
|
||||||
|
if (!mounted) return null;
|
||||||
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
||||||
try {
|
try {
|
||||||
final conversation =
|
final conversation =
|
||||||
await _repository.startConversation(agentProfileId);
|
await _repository.startConversation(agentProfileId);
|
||||||
|
|
||||||
|
if (!mounted) return conversation.id;
|
||||||
|
|
||||||
final exists = state.conversations.any((c) => c.id == conversation.id);
|
final exists = state.conversations.any((c) => c.id == conversation.id);
|
||||||
final updatedConversations = exists
|
final updatedConversations = exists
|
||||||
? state.conversations
|
? state.conversations
|
||||||
@@ -280,10 +395,12 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
|
|||||||
);
|
);
|
||||||
return conversation.id;
|
return conversation.id;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
state = state.copyWith(
|
if (mounted) {
|
||||||
isLoadingConversations: false,
|
state = state.copyWith(
|
||||||
error: 'Failed to start conversation',
|
isLoadingConversations: false,
|
||||||
);
|
error: 'Failed to start conversation',
|
||||||
|
);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/core/widgets/s3_image.dart';
|
||||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.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/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:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
bool _isComposing = false;
|
bool _isComposing = false;
|
||||||
int _previousMessageCount = 0;
|
int _previousMessageCount = 0;
|
||||||
|
|
||||||
|
final SocketService _socket = SocketService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -34,6 +37,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
notifier.setActiveConversation(widget.conversationId);
|
notifier.setActiveConversation(widget.conversationId);
|
||||||
notifier.loadMessages(widget.conversationId);
|
notifier.loadMessages(widget.conversationId);
|
||||||
notifier.markAsRead(widget.conversationId);
|
notifier.markAsRead(widget.conversationId);
|
||||||
|
// Join socket room for real-time updates
|
||||||
|
_socket.joinConversation(widget.conversationId);
|
||||||
});
|
});
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
_messageController.addListener(_onTextChanged);
|
_messageController.addListener(_onTextChanged);
|
||||||
@@ -41,6 +46,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
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);
|
ref.read(messagingProvider.notifier).setActiveConversation(null);
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
@@ -61,6 +71,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
final composing = _messageController.text.trim().isNotEmpty;
|
final composing = _messageController.text.trim().isNotEmpty;
|
||||||
if (composing != _isComposing) {
|
if (composing != _isComposing) {
|
||||||
setState(() => _isComposing = composing);
|
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();
|
final text = _messageController.text.trim();
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
|
_socket.stopTyping(widget.conversationId);
|
||||||
ref
|
ref
|
||||||
.read(messagingProvider.notifier)
|
.read(messagingProvider.notifier)
|
||||||
.sendMessage(widget.conversationId, text);
|
.sendMessage(widget.conversationId, text);
|
||||||
@@ -998,15 +1015,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
// Name row with timestamp
|
// Name row with timestamp
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
senderName,
|
child: Text(
|
||||||
style: const TextStyle(
|
senderName,
|
||||||
fontFamily: 'Fractul',
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontFamily: 'Fractul',
|
||||||
fontWeight: FontWeight.w700,
|
fontSize: 14,
|
||||||
color: AppColors.primaryDark,
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
|
|||||||
Reference in New Issue
Block a user