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:
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user