From 4c06d0d89588190b78174bd9ceb97b3e8979e3f4 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Fri, 27 Mar 2026 06:32:46 +0530 Subject: [PATCH] feat: Enhance messaging read receipts and auto-read, fix auth 401 error suppression during logout, and refine agent profile expertise --- lib/core/network/api_client.dart | 9 ++++++ .../agents/data/models/agent_profile.dart | 8 ++--- .../screens/agent_home_screen.dart | 13 ++++++-- .../screens/agent_search_screen.dart | 7 ++--- .../presentation/providers/auth_provider.dart | 5 ++++ .../data/models/messaging_models.dart | 20 +++++++++++++ .../providers/messaging_provider.dart | 30 +++++++++++++++++-- 7 files changed, 77 insertions(+), 15 deletions(-) diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 03ce20f..9c56e05 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -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 diff --git a/lib/features/agents/data/models/agent_profile.dart b/lib/features/agents/data/models/agent_profile.dart index eca80e6..eacc476 100644 --- a/lib/features/agents/data/models/agent_profile.dart +++ b/lib/features/agents/data/models/agent_profile.dart @@ -151,23 +151,21 @@ class AgentProfile { } /// Get specializations / expertise from field values. - /// Matches web's getExpertiseTags slugs. + /// Matches web's getExpertiseTags slugs exactly. List get specializations { final tags = []; + // 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)) { diff --git a/lib/features/agents/presentation/screens/agent_home_screen.dart b/lib/features/agents/presentation/screens/agent_home_screen.dart index 1eb1dd8..3c8957e 100644 --- a/lib/features/agents/presentation/screens/agent_home_screen.dart +++ b/lib/features/agents/presentation/screens/agent_home_screen.dart @@ -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( + 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, diff --git a/lib/features/agents/presentation/screens/agent_search_screen.dart b/lib/features/agents/presentation/screens/agent_search_screen.dart index 9d357d4..e5fbea8 100644 --- a/lib/features/agents/presentation/screens/agent_search_screen.dart +++ b/lib/features/agents/presentation/screens/agent_search_screen.dart @@ -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, ), ), ); diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index f8980b9..98e2efc 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -97,6 +97,8 @@ class AuthNotifier extends StateNotifier { 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 { } Future 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 { return; } + ApiClient.suppressAuthErrors = false; state = state.copyWith( status: AuthStatus.authenticated, user: result['user'] as UserModel, diff --git a/lib/features/messaging/data/models/messaging_models.dart b/lib/features/messaging/data/models/messaging_models.dart index 9cc6127..4bc6912 100644 --- a/lib/features/messaging/data/models/messaging_models.dart +++ b/lib/features/messaging/data/models/messaging_models.dart @@ -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 json) { return ChatMessage( id: json['id'] as String, diff --git a/lib/features/messaging/presentation/providers/messaging_provider.dart b/lib/features/messaging/presentation/providers/messaging_provider.dart index a602240..b65d1e2 100644 --- a/lib/features/messaging/presentation/providers/messaging_provider.dart +++ b/lib/features/messaging/presentation/providers/messaging_provider.dart @@ -143,6 +143,12 @@ class MessagingNotifier extends StateNotifier { 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 { } })); - // 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.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>.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