feat: Implement messaging functionality with conversation and chat screens, and enhance agent detail views.

This commit is contained in:
pradeepkumar
2026-03-08 00:41:44 +05:30
parent bdb7b04fbf
commit bba9cd3936
23 changed files with 4402 additions and 3 deletions

View File

@@ -0,0 +1,149 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logger/logger.dart';
import 'package:real_estate_mobile/core/constants/api_constants.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/features/messaging/data/models/messaging_models.dart';
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
class MessagingRepository {
final Dio _dio = ApiClient.instance.dio;
/// GET /messages/conversations
Future<List<Conversation>> getConversations() async {
try {
final response = await _dio.get(ApiConstants.conversations);
final data = response.data['data'] as List<dynamic>;
return data
.map((e) => Conversation.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch conversations',
statusCode: e.response?.statusCode,
);
} catch (e, stack) {
_log.e('Conversation parsing error', error: e, stackTrace: stack);
throw ApiException(message: 'Failed to parse conversations: $e');
}
}
/// POST /messages/conversations
Future<Conversation> startConversation(String agentProfileId) async {
try {
final response = await _dio.post(
ApiConstants.conversations,
data: {'agentProfileId': agentProfileId},
);
return Conversation.fromJson(
response.data['data'] as Map<String, dynamic>);
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to start conversation',
statusCode: e.response?.statusCode,
);
}
}
/// GET /messages/conversations/:id
Future<Conversation> getConversation(String id) async {
try {
final response = await _dio.get('${ApiConstants.conversations}/$id');
return Conversation.fromJson(
response.data['data'] as Map<String, dynamic>);
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch conversation',
statusCode: e.response?.statusCode,
);
}
}
/// GET /messages/conversations/:id/messages?page=&limit=
Future<({List<ChatMessage> messages, PaginationInfo pagination})> getMessages(
String conversationId, {
int page = 1,
int limit = 50,
}) async {
try {
final response = await _dio.get(
'${ApiConstants.conversations}/$conversationId/messages',
queryParameters: {'page': page, 'limit': limit},
);
final data = response.data['data'] as Map<String, dynamic>;
final messages = (data['messages'] as List<dynamic>)
.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>))
.toList();
final pagination = PaginationInfo.fromJson(
data['pagination'] as Map<String, dynamic>);
return (messages: messages, pagination: pagination);
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch messages',
statusCode: e.response?.statusCode,
);
}
}
/// POST /messages/conversations/:id/messages
Future<ChatMessage> sendMessage(
String conversationId, {
required String content,
MessageType type = MessageType.text,
}) async {
try {
final response = await _dio.post(
'${ApiConstants.conversations}/$conversationId/messages',
data: {
'content': content,
'messageType': type.toApiString(),
},
);
return ChatMessage.fromJson(
response.data['data'] as Map<String, dynamic>);
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to send message',
statusCode: e.response?.statusCode,
);
}
}
/// PATCH /messages/conversations/:id/read
Future<void> markAsRead(String conversationId) async {
try {
await _dio.patch('${ApiConstants.conversations}/$conversationId/read');
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to mark as read',
statusCode: e.response?.statusCode,
);
}
}
/// GET /messages/unread-count
Future<int> getUnreadCount() async {
try {
final response = await _dio.get(ApiConstants.unreadCount);
return response.data['data']['unreadCount'] as int? ?? 0;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch unread count',
statusCode: e.response?.statusCode,
);
}
}
}
final messagingRepositoryProvider = Provider<MessagingRepository>((ref) {
return MessagingRepository();
});

View File

@@ -0,0 +1,3 @@
// Re-export from the combined messaging models file.
export 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart'
show OtherParty, Conversation;

View File

@@ -0,0 +1,3 @@
// Re-export from the combined messaging models file.
export 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart'
show MessageType, MessageStatus, MessageSender, SenderProfile, ChatMessage, PaginationInfo;

View File

@@ -0,0 +1,281 @@
enum MessageType {
text,
file,
image,
system;
static MessageType fromString(String? value) {
switch (value?.toUpperCase()) {
case 'FILE':
return MessageType.file;
case 'IMAGE':
return MessageType.image;
case 'SYSTEM':
return MessageType.system;
default:
return MessageType.text;
}
}
String toApiString() => name.toUpperCase();
}
enum MessageStatus {
sent,
delivered,
read;
static MessageStatus fromString(String? value) {
switch (value?.toUpperCase()) {
case 'DELIVERED':
return MessageStatus.delivered;
case 'READ':
return MessageStatus.read;
default:
return MessageStatus.sent;
}
}
}
class OtherParty {
final String id;
final String? userId;
final String name;
final String? avatar;
final String? headline;
final bool isOnline;
final String? lastSeenAt;
const OtherParty({
required this.id,
this.userId,
required this.name,
this.avatar,
this.headline,
this.isOnline = false,
this.lastSeenAt,
});
factory OtherParty.fromJson(Map<String, dynamic> json) {
return OtherParty(
id: json['id'] as String,
userId: json['userId'] as String?,
name: json['name'] as String? ?? '',
avatar: json['avatar'] as String?,
headline: json['headline'] as String?,
isOnline: json['isOnline'] as bool? ?? false,
lastSeenAt: json['lastSeenAt'] as String?,
);
}
}
class Conversation {
final String id;
final String userId;
final String agentProfileId;
final String? lastMessageAt;
final String? lastMessageText;
final int userUnreadCount;
final int agentUnreadCount;
final int unreadCount;
final String createdAt;
final String updatedAt;
final OtherParty otherParty;
final List<ChatMessage>? messages;
const Conversation({
required this.id,
required this.userId,
required this.agentProfileId,
this.lastMessageAt,
this.lastMessageText,
this.userUnreadCount = 0,
this.agentUnreadCount = 0,
this.unreadCount = 0,
required this.createdAt,
required this.updatedAt,
required this.otherParty,
this.messages,
});
factory Conversation.fromJson(Map<String, dynamic> json) {
return Conversation(
id: json['id'] as String,
userId: json['userId'] as String,
agentProfileId: json['agentProfileId'] as String,
lastMessageAt: json['lastMessageAt'] as String?,
lastMessageText: json['lastMessageText'] as String?,
userUnreadCount: json['userUnreadCount'] as int? ?? 0,
agentUnreadCount: json['agentUnreadCount'] as int? ?? 0,
unreadCount: json['unreadCount'] as int? ?? 0,
createdAt: json['createdAt'] as String,
updatedAt: json['updatedAt'] as String,
otherParty:
OtherParty.fromJson(json['otherParty'] as Map<String, dynamic>),
messages: (json['messages'] as List<dynamic>?)
?.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Conversation copyWith({
int? unreadCount,
String? lastMessageAt,
String? lastMessageText,
OtherParty? otherParty,
List<ChatMessage>? messages,
}) {
return Conversation(
id: id,
userId: userId,
agentProfileId: agentProfileId,
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
lastMessageText: lastMessageText ?? this.lastMessageText,
userUnreadCount: userUnreadCount,
agentUnreadCount: agentUnreadCount,
unreadCount: unreadCount ?? this.unreadCount,
createdAt: createdAt,
updatedAt: updatedAt,
otherParty: otherParty ?? this.otherParty,
messages: messages ?? this.messages,
);
}
}
class SenderProfile {
final String? firstName;
final String? lastName;
final String? avatar;
const SenderProfile({this.firstName, this.lastName, this.avatar});
factory SenderProfile.fromJson(Map<String, dynamic> json) {
return SenderProfile(
firstName: json['firstName'] as String?,
lastName: json['lastName'] as String?,
avatar: json['avatar'] as String?,
);
}
}
class MessageSender {
final String id;
final String role;
final SenderProfile? userProfile;
final SenderProfile? agentProfile;
const MessageSender({
required this.id,
required this.role,
this.userProfile,
this.agentProfile,
});
String get displayName {
final profile = userProfile ?? agentProfile;
if (profile == null) return 'Unknown';
return '${profile.firstName ?? ''} ${profile.lastName ?? ''}'.trim();
}
String? get avatar => userProfile?.avatar ?? agentProfile?.avatar;
factory MessageSender.fromJson(Map<String, dynamic> json) {
return MessageSender(
id: json['id'] as String,
role: json['role'] as String? ?? '',
userProfile: json['userProfile'] != null
? SenderProfile.fromJson(
json['userProfile'] as Map<String, dynamic>)
: null,
agentProfile: json['agentProfile'] != null
? SenderProfile.fromJson(
json['agentProfile'] as Map<String, dynamic>)
: null,
);
}
}
class ChatMessage {
final String id;
final String conversationId;
final String senderId;
final String content;
final MessageType messageType;
final String? fileUrl;
final String? fileName;
final int? fileSize;
final String? mimeType;
final MessageStatus status;
final String? deliveredAt;
final String? readAt;
final String createdAt;
final String updatedAt;
final MessageSender sender;
const ChatMessage({
required this.id,
required this.conversationId,
required this.senderId,
required this.content,
this.messageType = MessageType.text,
this.fileUrl,
this.fileName,
this.fileSize,
this.mimeType,
this.status = MessageStatus.sent,
this.deliveredAt,
this.readAt,
required this.createdAt,
required this.updatedAt,
required this.sender,
});
bool isMine(String currentUserId) => senderId == currentUserId;
factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage(
id: json['id'] as String,
conversationId: json['conversationId'] as String,
senderId: json['senderId'] as String,
content: json['content'] as String? ?? '',
messageType: MessageType.fromString(json['messageType'] as String?),
fileUrl: json['fileUrl'] as String?,
fileName: json['fileName'] as String?,
fileSize: json['fileSize'] as int?,
mimeType: json['mimeType'] as String?,
status: MessageStatus.fromString(json['status'] as String?),
deliveredAt: json['deliveredAt'] as String?,
readAt: json['readAt'] as String?,
createdAt: json['createdAt'] as String,
updatedAt: json['updatedAt'] as String,
sender: json['sender'] != null
? MessageSender.fromJson(json['sender'] as Map<String, dynamic>)
: MessageSender(id: json['senderId'] as String, role: ''),
);
}
}
class PaginationInfo {
final int page;
final int limit;
final int total;
final int pages;
const PaginationInfo({
required this.page,
required this.limit,
required this.total,
required this.pages,
});
bool get hasMore => page < pages;
factory PaginationInfo.fromJson(Map<String, dynamic> json) {
return PaginationInfo(
page: json['page'] as int? ?? 1,
limit: json['limit'] as int? ?? 50,
total: json['total'] as int? ?? 0,
pages: json['pages'] as int? ?? 1,
);
}
}

View File

@@ -0,0 +1,204 @@
import 'dart:async';
import 'package:logger/logger.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/core/storage/secure_storage.dart';
import 'package:real_estate_mobile/features/messaging/data/models/message.dart';
// We'll use a simple WebSocket approach for now since socket_io_client needs to be added
// This will be a placeholder that uses REST polling until socket_io_client is added
import 'package:socket_io_client/socket_io_client.dart' as io;
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
class SocketService {
static final SocketService _instance = SocketService._();
factory SocketService() => _instance;
SocketService._();
io.Socket? _socket;
bool _isConnected = false;
String? _currentToken;
// Stream controllers for events
final _messageController = StreamController<ChatMessage>.broadcast();
final _typingStartController = StreamController<Map<String, dynamic>>.broadcast();
final _typingStopController = StreamController<Map<String, dynamic>>.broadcast();
final _statusController = StreamController<Map<String, dynamic>>.broadcast();
final _readController = StreamController<Map<String, dynamic>>.broadcast();
final _connectionController = StreamController<bool>.broadcast();
Stream<ChatMessage> get onNewMessage => _messageController.stream;
Stream<Map<String, dynamic>> get onTypingStart => _typingStartController.stream;
Stream<Map<String, dynamic>> get onTypingStop => _typingStopController.stream;
Stream<Map<String, dynamic>> get onStatusChange => _statusController.stream;
Stream<Map<String, dynamic>> get onMessagesRead => _readController.stream;
Stream<bool> get onConnectionChange => _connectionController.stream;
bool get isConnected => _isConnected;
Future<void> connect() async {
final token = await SecureStorage.getAccessToken();
if (token == null) {
_log.w('No access token for socket connection');
return;
}
if (_socket?.connected == true && _currentToken == token) return;
_currentToken = token;
// Strip /api/v1 from base URL for socket connection
final apiUrl = AppConfig.apiBaseUrl;
final baseUrl = apiUrl.replaceAll(RegExp(r'/api/v1/?$'), '');
_socket?.disconnect();
_socket?.dispose();
_socket = io.io(baseUrl, io.OptionBuilder()
.setTransports(['websocket', 'polling'])
.setAuth({'token': token})
.disableAutoConnect()
.enableReconnection()
.setReconnectionAttempts(5)
.setReconnectionDelay(1000)
.setReconnectionDelayMax(5000)
.build(),
);
_socket!.onConnect((_) {
_log.i('Socket connected: ${_socket!.id}');
_isConnected = true;
_connectionController.add(true);
});
_socket!.onDisconnect((reason) {
_log.w('Socket disconnected: $reason');
_isConnected = false;
_connectionController.add(false);
if (reason == 'io server disconnect') {
_log.w('Server rejected connection - token likely expired');
_reconnectWithFreshToken();
}
});
_socket!.onConnectError((error) {
_log.e('Socket connect error: $error');
_isConnected = false;
_connectionController.add(false);
});
// Listen for events
_socket!.on('new_message', (data) {
try {
final message = ChatMessage.fromJson(data as Map<String, dynamic>);
_messageController.add(message);
} catch (e) {
_log.e('Error parsing new_message: $e');
}
});
_socket!.on('typing_start', (data) {
_typingStartController.add(Map<String, dynamic>.from(data as Map));
});
_socket!.on('typing_stop', (data) {
_typingStopController.add(Map<String, dynamic>.from(data as Map));
});
_socket!.on('user_status_change', (data) {
_statusController.add(Map<String, dynamic>.from(data as Map));
});
_socket!.on('messages_read', (data) {
_readController.add(Map<String, dynamic>.from(data as Map));
});
_socket!.connect();
}
Future<void> _reconnectWithFreshToken() async {
final token = await SecureStorage.getAccessToken();
if (token != null && token != _currentToken) {
_currentToken = token;
_socket?.io.options?['auth'] = {'token': token};
_socket?.connect();
}
}
void disconnect() {
_socket?.disconnect();
_socket?.dispose();
_socket = null;
_isConnected = false;
_currentToken = null;
}
Future<void> joinConversation(String conversationId) async {
if (_socket == null || !_isConnected) return;
final completer = Completer<void>();
_socket!.emitWithAck('join_conversation', {'conversationId': conversationId},
ack: (data) => completer.complete(),
);
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () => _log.w('join_conversation timeout'),
);
}
void leaveConversation(String conversationId) {
_socket?.emit('leave_conversation', {'conversationId': conversationId});
}
Future<ChatMessage?> sendMessage(
String conversationId,
Map<String, dynamic> messageData,
) async {
if (_socket == null || !_isConnected) return null;
final completer = Completer<ChatMessage?>();
_socket!.emitWithAck('send_message', {
'conversationId': conversationId,
'message': messageData,
}, ack: (data) {
try {
final response = data as Map<String, dynamic>;
if (response['success'] == true && response['message'] != null) {
completer.complete(
ChatMessage.fromJson(response['message'] as Map<String, dynamic>),
);
} else {
completer.complete(null);
}
} catch (e) {
completer.complete(null);
}
});
return completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () => null,
);
}
void startTyping(String conversationId) {
_socket?.emit('typing_start', {'conversationId': conversationId});
}
void stopTyping(String conversationId) {
_socket?.emit('typing_stop', {'conversationId': conversationId});
}
void markAsRead(String conversationId) {
_socket?.emit('mark_read', {'conversationId': conversationId});
}
void dispose() {
disconnect();
_messageController.close();
_typingStartController.close();
_typingStopController.close();
_statusController.close();
_readController.close();
_connectionController.close();
}
}