feat: Enhance messaging read receipts and auto-read, fix auth 401 error suppression during logout, and refine agent profile expertise

This commit is contained in:
pradeepkumar
2026-03-27 06:32:46 +05:30
parent ba87e3b091
commit 4c06d0d895
7 changed files with 77 additions and 15 deletions

View File

@@ -14,6 +14,9 @@ class ApiClient {
bool _isRefreshing = false;
final List<_QueuedRequest> _failedQueue = [];
// Suppress 401 errors during logout to prevent SnackBar flashing
static bool suppressAuthErrors = false;
// Callback to notify app of forced logout
static void Function()? onForceLogout;
@@ -60,6 +63,12 @@ class ApiClient {
onError: (error, handler) async {
// Handle 401 — attempt token refresh
if (error.response?.statusCode == 401) {
// During logout, silently reject 401s without showing errors
if (suppressAuthErrors) {
handler.reject(error);
return;
}
final requestUrl = error.requestOptions.path;
// Don't try to refresh if this was the refresh request itself

View File

@@ -151,23 +151,21 @@ class AgentProfile {
}
/// Get specializations / expertise from field values.
/// Matches web's getExpertiseTags slugs.
/// Matches web's getExpertiseTags slugs exactly.
List<String> get specializations {
final tags = <String>[];
// Must match web's expertiseFieldSlugs exactly (exact match, not contains)
const expertiseSlugs = [
'about_me_expertise',
'expertise_areas',
'specializations',
'areas_of_expertise',
'expertise',
'specialization',
'property_type',
'loan_type',
];
for (final fv in fieldValues) {
final slug = fv.fieldSlug.toLowerCase();
if (!expertiseSlugs.any((s) => slug.contains(s))) continue;
if (!expertiseSlugs.contains(slug)) continue;
if (fv.jsonValue is List) {
for (final v in (fv.jsonValue as List)) {

View File

@@ -1299,6 +1299,13 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
Widget _buildTestimonialsSection(AgentDetailState state) {
if (state.testimonials.isEmpty) return const SizedBox.shrink();
// Calculate average rating dynamically
final totalRating = state.testimonials.fold<double>(
0, (sum, t) => sum + ((t['rating'] as num?)?.toDouble() ?? 5.0),
);
final avgRating = (totalRating / state.testimonials.length).toStringAsFixed(1);
final count = state.testimonials.length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
@@ -1318,9 +1325,9 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
height: 1,
),
const SizedBox(height: 8),
const Text(
'Clients rate our real estate services 4.9 out of 5 on average, based on recent client reviews.',
style: TextStyle(
Text(
'Clients rate our real estate services $avgRating out of 5 on average, based on $count recent client review${count != 1 ? 's' : ''}.',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,

View File

@@ -798,11 +798,9 @@ class _AgentCardState extends State<_AgentCard> {
Widget _buildTag(String label) {
return Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.primaryDark, width: 0.7),
),
child: Text(
@@ -812,7 +810,6 @@ class _AgentCardState extends State<_AgentCard> {
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
height: 1,
),
),
);

View File

@@ -97,6 +97,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
try {
final user = await _repository.getMe();
// Re-enable auth error handling after successful login
ApiClient.suppressAuthErrors = false;
state = state.copyWith(
status: AuthStatus.authenticated,
user: user,
@@ -113,6 +115,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<void> logout() async {
// Suppress 401 errors during logout to prevent SnackBar flashing
ApiClient.suppressAuthErrors = true;
// Reset state first so UI navigates to login immediately
state = const AuthState();
// Disconnect socket immediately
@@ -151,6 +155,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
return;
}
ApiClient.suppressAuthErrors = false;
state = state.copyWith(
status: AuthStatus.authenticated,
user: result['user'] as UserModel,

View File

@@ -264,6 +264,26 @@ class ChatMessage {
bool isMine(String currentUserId) => senderId == currentUserId;
ChatMessage copyWith({MessageStatus? status, String? readAt, String? deliveredAt}) {
return ChatMessage(
id: id,
conversationId: conversationId,
senderId: senderId,
content: content,
messageType: messageType,
fileUrl: fileUrl,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
status: status ?? this.status,
deliveredAt: deliveredAt ?? this.deliveredAt,
readAt: readAt ?? this.readAt,
createdAt: createdAt,
updatedAt: updatedAt,
sender: sender,
);
}
factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage(
id: json['id'] as String,

View File

@@ -143,6 +143,12 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
conversations: updatedConversations,
unreadCount: totalUnread,
);
// Auto-mark as read if user is currently viewing this conversation
final isActive = state.activeConversationId == convId;
if (isActive) {
markAsRead(convId);
}
}));
// Typing indicators
@@ -165,16 +171,36 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
}
}));
// Read receipts
// Read receipts — update message ticks and conversation unread count
_subscriptions.add(_socket.onMessagesRead.listen((data) {
if (!mounted) return;
final convId = data['conversationId'] as String?;
final readAt = data['readAt'] as String?;
if (convId == null) return;
// Update conversation unread count
final updatedConversations = state.conversations.map((c) {
if (c.id == convId) return c.copyWith(unreadCount: 0);
return c;
}).toList();
state = state.copyWith(conversations: updatedConversations);
// Update message statuses — mark current user's sent messages as READ
final currentMessages = List<ChatMessage>.from(state.messages[convId] ?? []);
final currentUserId = _currentUserId;
bool messagesChanged = false;
final updatedMessages = currentMessages.map((m) {
if (m.senderId == currentUserId && m.status != MessageStatus.read) {
messagesChanged = true;
return m.copyWith(status: MessageStatus.read, readAt: readAt);
}
return m;
}).toList();
final newMessages = messagesChanged
? (Map<String, List<ChatMessage>>.from(state.messages)..[convId] = updatedMessages)
: state.messages;
state = state.copyWith(conversations: updatedConversations, messages: newMessages);
}));
// Reconnection: re-join active conversation room and catch up missed messages