feat: Implement dynamic top professionals section with agent/lender tabs using Riverpod for state management.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
class ApiConstants {
|
||||
ApiConstants._();
|
||||
|
||||
// Auth
|
||||
static const String authRegister = '/auth/register';
|
||||
static const String authLogin = '/auth/login';
|
||||
static const String authForgotPassword = '/auth/forgot-password';
|
||||
@@ -10,4 +11,8 @@ class ApiConstants {
|
||||
static const String authMe = '/auth/me';
|
||||
static const String authRefresh = '/auth/refresh';
|
||||
static const String authLogout = '/auth/logout';
|
||||
|
||||
// Agents
|
||||
static const String agents = '/agents';
|
||||
static const String agentTypes = '/agent-types';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:real_estate_mobile/config/app_config.dart';
|
||||
import 'package:real_estate_mobile/core/constants/api_constants.dart';
|
||||
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
|
||||
import 'package:real_estate_mobile/core/storage/secure_storage.dart';
|
||||
|
||||
@@ -7,6 +10,13 @@ class ApiClient {
|
||||
static ApiClient? _instance;
|
||||
late final Dio _dio;
|
||||
|
||||
// Token refresh state
|
||||
bool _isRefreshing = false;
|
||||
final List<_QueuedRequest> _failedQueue = [];
|
||||
|
||||
// Callback to notify app of forced logout
|
||||
static void Function()? onForceLogout;
|
||||
|
||||
ApiClient._() {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
@@ -47,7 +57,166 @@ class ApiClient {
|
||||
|
||||
InterceptorsWrapper _errorInterceptor() {
|
||||
return InterceptorsWrapper(
|
||||
onError: (error, handler) {
|
||||
onError: (error, handler) async {
|
||||
// Handle 401 — attempt token refresh
|
||||
if (error.response?.statusCode == 401) {
|
||||
final requestUrl = error.requestOptions.path;
|
||||
|
||||
// Don't try to refresh if this was the refresh request itself
|
||||
if (requestUrl.contains(ApiConstants.authRefresh)) {
|
||||
await _forceLogout();
|
||||
handler.reject(_wrapError(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't refresh for auth endpoints (login, register, etc.)
|
||||
if (_isAuthEndpoint(requestUrl)) {
|
||||
handler.reject(_wrapError(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// If already refreshing, queue this request
|
||||
if (_isRefreshing) {
|
||||
try {
|
||||
final response = await _queueRequest(error.requestOptions);
|
||||
handler.resolve(response);
|
||||
} catch (e) {
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
error: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt refresh
|
||||
_isRefreshing = true;
|
||||
|
||||
try {
|
||||
final newAccessToken = await _refreshToken();
|
||||
|
||||
if (newAccessToken != null) {
|
||||
// Retry original request with new token
|
||||
error.requestOptions.headers['Authorization'] =
|
||||
'Bearer $newAccessToken';
|
||||
|
||||
// Process queued requests
|
||||
_processQueue(null, newAccessToken);
|
||||
_isRefreshing = false;
|
||||
|
||||
// Retry the original request
|
||||
final response = await _dio.fetch(error.requestOptions);
|
||||
handler.resolve(response);
|
||||
return;
|
||||
} else {
|
||||
// No refresh token available
|
||||
_processQueue(error);
|
||||
_isRefreshing = false;
|
||||
await _forceLogout();
|
||||
handler.reject(_wrapError(error));
|
||||
return;
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed
|
||||
_processQueue(error);
|
||||
_isRefreshing = false;
|
||||
await _forceLogout();
|
||||
handler.reject(_wrapError(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-401 errors — parse as before
|
||||
handler.reject(_wrapError(error));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Attempt to refresh the access token using the stored refresh token.
|
||||
Future<String?> _refreshToken() async {
|
||||
final refreshToken = await SecureStorage.getRefreshToken();
|
||||
if (refreshToken == null) return null;
|
||||
|
||||
// Use a separate Dio instance to avoid interceptor loops
|
||||
final refreshDio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final response = await refreshDio.post(
|
||||
ApiConstants.authRefresh,
|
||||
data: {'refreshToken': refreshToken},
|
||||
);
|
||||
|
||||
final data = response.data['data'] as Map<String, dynamic>;
|
||||
final newAccessToken = data['accessToken'] as String;
|
||||
final newRefreshToken = data['refreshToken'] as String;
|
||||
|
||||
await SecureStorage.saveTokens(
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
);
|
||||
|
||||
return newAccessToken;
|
||||
}
|
||||
|
||||
/// Queue a request to be retried after token refresh completes.
|
||||
Future<Response> _queueRequest(RequestOptions requestOptions) {
|
||||
final completer = Completer<Response>();
|
||||
_failedQueue.add(_QueuedRequest(
|
||||
completer: completer,
|
||||
requestOptions: requestOptions,
|
||||
));
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Process all queued requests after refresh succeeds or fails.
|
||||
void _processQueue(DioException? error, [String? newToken]) {
|
||||
for (final queued in _failedQueue) {
|
||||
if (error != null) {
|
||||
queued.completer.completeError(error);
|
||||
} else {
|
||||
// Update token and retry
|
||||
queued.requestOptions.headers['Authorization'] =
|
||||
'Bearer $newToken';
|
||||
_dio.fetch(queued.requestOptions).then(
|
||||
queued.completer.complete,
|
||||
onError: queued.completer.completeError,
|
||||
);
|
||||
}
|
||||
}
|
||||
_failedQueue.clear();
|
||||
}
|
||||
|
||||
/// Force logout — clear tokens and notify the app.
|
||||
Future<void> _forceLogout() async {
|
||||
await SecureStorage.clearTokens();
|
||||
onForceLogout?.call();
|
||||
}
|
||||
|
||||
/// Check if the URL is an auth endpoint that shouldn't trigger refresh.
|
||||
bool _isAuthEndpoint(String url) {
|
||||
const authPaths = [
|
||||
ApiConstants.authLogin,
|
||||
ApiConstants.authRegister,
|
||||
ApiConstants.authForgotPassword,
|
||||
ApiConstants.authResetPassword,
|
||||
ApiConstants.authVerifyEmail,
|
||||
];
|
||||
return authPaths.any((path) => url.contains(path));
|
||||
}
|
||||
|
||||
/// Wrap a DioException with parsed error data.
|
||||
DioException _wrapError(DioException error) {
|
||||
final response = error.response;
|
||||
if (response != null) {
|
||||
final data = response.data;
|
||||
@@ -57,12 +226,12 @@ class ApiClient {
|
||||
: data['message']?.toString() ?? 'An error occurred';
|
||||
|
||||
final errors = (data['errors'] as List<dynamic>?)
|
||||
?.map((e) => FieldError.fromJson(e as Map<String, dynamic>))
|
||||
?.map(
|
||||
(e) => FieldError.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
handler.reject(
|
||||
DioException(
|
||||
return DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
error: ApiException(
|
||||
@@ -70,23 +239,27 @@ class ApiClient {
|
||||
statusCode: response.statusCode,
|
||||
fieldErrors: errors,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handler.reject(
|
||||
DioException(
|
||||
return DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
error: ApiException(
|
||||
message: error.message ?? 'Network error occurred',
|
||||
statusCode: response?.statusCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QueuedRequest {
|
||||
final Completer<Response> completer;
|
||||
final RequestOptions requestOptions;
|
||||
|
||||
_QueuedRequest({
|
||||
required this.completer,
|
||||
required this.requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
91
lib/features/agents/data/agents_repository.dart
Normal file
91
lib/features/agents/data/agents_repository.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:dio/dio.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/agents/data/models/agent_profile.dart';
|
||||
import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart';
|
||||
|
||||
class SearchAgentsResponse {
|
||||
final List<AgentProfile> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int limit;
|
||||
final int totalPages;
|
||||
|
||||
const SearchAgentsResponse({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.totalPages,
|
||||
});
|
||||
|
||||
factory SearchAgentsResponse.fromJson(Map<String, dynamic> json) {
|
||||
return SearchAgentsResponse(
|
||||
data: (json['data'] as List<dynamic>)
|
||||
.map((e) => AgentProfile.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int? ?? 0,
|
||||
page: json['page'] as int? ?? 1,
|
||||
limit: json['limit'] as int? ?? 10,
|
||||
totalPages: json['totalPages'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AgentsRepository {
|
||||
final Dio _dio = ApiClient.instance.dio;
|
||||
|
||||
Future<SearchAgentsResponse> searchAgents({
|
||||
String? agentTypeId,
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
String? sortBy,
|
||||
String? sortOrder,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
};
|
||||
|
||||
if (agentTypeId != null) queryParams['agentTypeId'] = agentTypeId;
|
||||
if (sortBy != null) queryParams['sortBy'] = sortBy;
|
||||
if (sortOrder != null) queryParams['sortOrder'] = sortOrder;
|
||||
|
||||
final response = await _dio.get(
|
||||
ApiConstants.agents,
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
final data = response.data['data'] as Map<String, dynamic>;
|
||||
return SearchAgentsResponse.fromJson(data);
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) {
|
||||
throw e.error as ApiException;
|
||||
}
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch agents',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<AgentType>> getAgentTypes() async {
|
||||
try {
|
||||
final response = await _dio.get(ApiConstants.agentTypes);
|
||||
final data = response.data['data'] as List<dynamic>;
|
||||
return data
|
||||
.map((e) => AgentType.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 agent types',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
lib/features/agents/data/models/agent_profile.dart
Normal file
165
lib/features/agents/data/models/agent_profile.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart';
|
||||
|
||||
class AgentProfile {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String slug;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
final String? bio;
|
||||
final String? avatar;
|
||||
final String? licenseNumber;
|
||||
final int? experience;
|
||||
final List<String> specializations;
|
||||
final List<String> languages;
|
||||
final List<String> serviceAreas;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? country;
|
||||
final double? rating;
|
||||
final int reviewCount;
|
||||
final bool isVerified;
|
||||
final bool isFeatured;
|
||||
final bool isAvailable;
|
||||
final String? agentTypeId;
|
||||
final AgentType? agentType;
|
||||
final List<AgentFieldValue> fieldValues;
|
||||
|
||||
const AgentProfile({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.slug,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.bio,
|
||||
this.avatar,
|
||||
this.licenseNumber,
|
||||
this.experience,
|
||||
this.specializations = const [],
|
||||
this.languages = const [],
|
||||
this.serviceAreas = const [],
|
||||
this.city,
|
||||
this.state,
|
||||
this.country,
|
||||
this.rating,
|
||||
this.reviewCount = 0,
|
||||
this.isVerified = false,
|
||||
this.isFeatured = false,
|
||||
this.isAvailable = false,
|
||||
this.agentTypeId,
|
||||
this.agentType,
|
||||
this.fieldValues = const [],
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName'.trim();
|
||||
|
||||
String get location {
|
||||
if (city != null && state != null) return '$city, $state';
|
||||
if (city != null) return city!;
|
||||
if (state != null) return state!;
|
||||
if (serviceAreas.isNotEmpty) return serviceAreas.first;
|
||||
return '';
|
||||
}
|
||||
|
||||
String get experienceText {
|
||||
if (experience == null) return '';
|
||||
return '$experience+ years in the real estate industry.';
|
||||
}
|
||||
|
||||
factory AgentProfile.fromJson(Map<String, dynamic> json) {
|
||||
return AgentProfile(
|
||||
id: json['id'] as String,
|
||||
firstName: json['firstName'] as String? ?? '',
|
||||
lastName: json['lastName'] as String? ?? '',
|
||||
slug: json['slug'] as String? ?? '',
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
avatar: json['avatar'] as String?,
|
||||
licenseNumber: json['licenseNumber'] as String?,
|
||||
experience: json['experience'] as int?,
|
||||
specializations: _parseStringList(json['specializations']),
|
||||
languages: _parseStringList(json['languages']),
|
||||
serviceAreas: _parseStringList(json['serviceAreas']),
|
||||
city: json['city'] as String?,
|
||||
state: json['state'] as String?,
|
||||
country: json['country'] as String?,
|
||||
rating: (json['rating'] as num?)?.toDouble(),
|
||||
reviewCount: json['reviewCount'] as int? ?? 0,
|
||||
isVerified: json['isVerified'] as bool? ?? false,
|
||||
isFeatured: json['isFeatured'] as bool? ?? false,
|
||||
isAvailable: json['isAvailable'] as bool? ?? false,
|
||||
agentTypeId: json['agentTypeId'] as String?,
|
||||
agentType: json['agentType'] != null
|
||||
? AgentType.fromJson(json['agentType'] as Map<String, dynamic>)
|
||||
: null,
|
||||
fieldValues: (json['fieldValues'] as List<dynamic>?)
|
||||
?.map((e) => AgentFieldValue.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _parseStringList(dynamic value) {
|
||||
if (value == null) return const [];
|
||||
if (value is List) return value.map((e) => e.toString()).toList();
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
class AgentFieldValue {
|
||||
final String id;
|
||||
final String fieldId;
|
||||
final String? textValue;
|
||||
final num? numberValue;
|
||||
final bool? booleanValue;
|
||||
final dynamic jsonValue;
|
||||
final String? dateValue;
|
||||
final AgentField field;
|
||||
|
||||
const AgentFieldValue({
|
||||
required this.id,
|
||||
required this.fieldId,
|
||||
this.textValue,
|
||||
this.numberValue,
|
||||
this.booleanValue,
|
||||
this.jsonValue,
|
||||
this.dateValue,
|
||||
required this.field,
|
||||
});
|
||||
|
||||
factory AgentFieldValue.fromJson(Map<String, dynamic> json) {
|
||||
return AgentFieldValue(
|
||||
id: json['id'] as String,
|
||||
fieldId: json['fieldId'] as String,
|
||||
textValue: json['textValue'] as String?,
|
||||
numberValue: json['numberValue'] as num?,
|
||||
booleanValue: json['booleanValue'] as bool?,
|
||||
jsonValue: json['jsonValue'],
|
||||
dateValue: json['dateValue'] as String?,
|
||||
field: AgentField.fromJson(json['field'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AgentField {
|
||||
final String slug;
|
||||
final String name;
|
||||
final String fieldType;
|
||||
|
||||
const AgentField({
|
||||
required this.slug,
|
||||
required this.name,
|
||||
required this.fieldType,
|
||||
});
|
||||
|
||||
factory AgentField.fromJson(Map<String, dynamic> json) {
|
||||
return AgentField(
|
||||
slug: json['slug'] as String,
|
||||
name: json['name'] as String,
|
||||
fieldType: json['fieldType'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/features/agents/data/models/agent_type.dart
Normal file
22
lib/features/agents/data/models/agent_type.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
class AgentType {
|
||||
final String id;
|
||||
final String name;
|
||||
final String slug;
|
||||
final String? description;
|
||||
|
||||
const AgentType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.slug,
|
||||
this.description,
|
||||
});
|
||||
|
||||
factory AgentType.fromJson(Map<String, dynamic> json) {
|
||||
return AgentType(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
slug: json['slug'] as String,
|
||||
description: json['description'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
129
lib/features/agents/presentation/providers/agents_provider.dart
Normal file
129
lib/features/agents/presentation/providers/agents_provider.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:real_estate_mobile/features/agents/data/agents_repository.dart';
|
||||
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
|
||||
import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart';
|
||||
|
||||
// Repository provider
|
||||
final agentsRepositoryProvider = Provider<AgentsRepository>((ref) {
|
||||
return AgentsRepository();
|
||||
});
|
||||
|
||||
// Agent types provider
|
||||
final agentTypesProvider = FutureProvider<List<AgentType>>((ref) async {
|
||||
final repository = ref.watch(agentsRepositoryProvider);
|
||||
return repository.getAgentTypes();
|
||||
});
|
||||
|
||||
// Top professionals state
|
||||
class TopProfessionalsState {
|
||||
final List<AgentProfile> agents;
|
||||
final List<AgentProfile> lenders;
|
||||
final List<AgentType> agentTypes;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final String activeTab; // 'agents' or 'lenders'
|
||||
|
||||
const TopProfessionalsState({
|
||||
this.agents = const [],
|
||||
this.lenders = const [],
|
||||
this.agentTypes = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.activeTab = 'agents',
|
||||
});
|
||||
|
||||
TopProfessionalsState copyWith({
|
||||
List<AgentProfile>? agents,
|
||||
List<AgentProfile>? lenders,
|
||||
List<AgentType>? agentTypes,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
String? activeTab,
|
||||
}) {
|
||||
return TopProfessionalsState(
|
||||
agents: agents ?? this.agents,
|
||||
lenders: lenders ?? this.lenders,
|
||||
agentTypes: agentTypes ?? this.agentTypes,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
activeTab: activeTab ?? this.activeTab,
|
||||
);
|
||||
}
|
||||
|
||||
List<AgentProfile> get activeList =>
|
||||
activeTab == 'agents' ? agents : lenders;
|
||||
}
|
||||
|
||||
class TopProfessionalsNotifier extends StateNotifier<TopProfessionalsState> {
|
||||
final AgentsRepository _repository;
|
||||
|
||||
TopProfessionalsNotifier(this._repository)
|
||||
: super(const TopProfessionalsState()) {
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Fetch agent types first to find agent/lender type IDs
|
||||
final agentTypes = await _repository.getAgentTypes();
|
||||
state = state.copyWith(agentTypes: agentTypes);
|
||||
|
||||
// Find agent and lender type IDs
|
||||
String? agentTypeId;
|
||||
String? lenderTypeId;
|
||||
|
||||
for (final type in agentTypes) {
|
||||
final slug = type.slug.toLowerCase();
|
||||
if (slug.contains('agent')) {
|
||||
agentTypeId = type.id;
|
||||
} else if (slug.contains('lender')) {
|
||||
lenderTypeId = type.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch agents and lenders in parallel
|
||||
final results = await Future.wait([
|
||||
_repository.searchAgents(
|
||||
agentTypeId: agentTypeId,
|
||||
limit: 10,
|
||||
sortBy: 'averageRating',
|
||||
sortOrder: 'desc',
|
||||
),
|
||||
_repository.searchAgents(
|
||||
agentTypeId: lenderTypeId,
|
||||
limit: 10,
|
||||
sortBy: 'averageRating',
|
||||
sortOrder: 'desc',
|
||||
),
|
||||
]);
|
||||
|
||||
state = state.copyWith(
|
||||
agents: results[0].data,
|
||||
lenders: results[1].data,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to load professionals',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void setActiveTab(String tab) {
|
||||
state = state.copyWith(activeTab: tab);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await _loadData();
|
||||
}
|
||||
}
|
||||
|
||||
final topProfessionalsProvider =
|
||||
StateNotifierProvider<TopProfessionalsNotifier, TopProfessionalsState>(
|
||||
(ref) {
|
||||
final repository = ref.watch(agentsRepositoryProvider);
|
||||
return TopProfessionalsNotifier(repository);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/core/storage/secure_storage.dart';
|
||||
import 'package:real_estate_mobile/features/auth/data/auth_repository.dart';
|
||||
@@ -51,6 +52,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final AuthRepository _repository;
|
||||
|
||||
AuthNotifier(this._repository) : super(const AuthState()) {
|
||||
// Wire up force logout callback so API client can reset auth state
|
||||
// when token refresh fails
|
||||
ApiClient.onForceLogout = () {
|
||||
state = const AuthState();
|
||||
};
|
||||
checkAuthStatus();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
|
||||
import 'package:real_estate_mobile/features/agents/presentation/providers/agents_provider.dart';
|
||||
|
||||
class TopProfessionalsSection extends StatelessWidget {
|
||||
class TopProfessionalsSection extends ConsumerWidget {
|
||||
const TopProfessionalsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(topProfessionalsProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
@@ -24,15 +29,38 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Agents / Lenders Tab
|
||||
_buildCategoryTab(),
|
||||
_buildCategoryTab(ref, state.activeTab),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Agent Card
|
||||
_buildAgentCard(),
|
||||
// Content
|
||||
if (state.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
)
|
||||
else if (state.error != null)
|
||||
_buildErrorState(ref, state.error!)
|
||||
else if (state.activeList.isEmpty)
|
||||
_buildEmptyState(state.activeTab)
|
||||
else ...[
|
||||
// Agent Cards - horizontal scroll
|
||||
SizedBox(
|
||||
height: 480,
|
||||
child: PageView.builder(
|
||||
controller: PageController(viewportFraction: 1.0),
|
||||
itemCount: state.activeList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildAgentCard(state.activeList[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Progress indicator
|
||||
_buildProgressIndicator(),
|
||||
_buildProgressIndicator(state.activeList.length),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// See All Agents CTA
|
||||
@@ -42,7 +70,7 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryTab() {
|
||||
Widget _buildCategoryTab(WidgetRef ref, String activeTab) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
@@ -50,12 +78,18 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Active tab - Agents
|
||||
// Agents tab
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => ref
|
||||
.read(topProfessionalsProvider.notifier)
|
||||
.setActiveTab('agents'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentOrange,
|
||||
color: activeTab == 'agents'
|
||||
? AppColors.accentOrange
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Row(
|
||||
@@ -65,30 +99,45 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
'assets/icons/agents_tab_icon.svg',
|
||||
width: 24,
|
||||
height: 24,
|
||||
placeholderBuilder: (_) => const Icon(
|
||||
placeholderBuilder: (_) => Icon(
|
||||
Icons.people,
|
||||
color: Colors.white,
|
||||
color: activeTab == 'agents'
|
||||
? Colors.white
|
||||
: AppColors.primaryDark,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
Text(
|
||||
'Agents',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
color: activeTab == 'agents'
|
||||
? Colors.white
|
||||
: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Inactive tab - Lenders
|
||||
),
|
||||
// Lenders tab
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => ref
|
||||
.read(topProfessionalsProvider.notifier)
|
||||
.setActiveTab('lenders'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: activeTab == 'lenders'
|
||||
? AppColors.accentOrange
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -96,32 +145,37 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
'assets/icons/lenders_tab_icon.svg',
|
||||
width: 24,
|
||||
height: 24,
|
||||
placeholderBuilder: (_) => const Icon(
|
||||
placeholderBuilder: (_) => Icon(
|
||||
Icons.calendar_today,
|
||||
color: AppColors.primaryDark,
|
||||
color: activeTab == 'lenders'
|
||||
? Colors.white
|
||||
: AppColors.primaryDark,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
Text(
|
||||
'Lenders',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryDark,
|
||||
color: activeTab == 'lenders'
|
||||
? Colors.white
|
||||
: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgentCard() {
|
||||
Widget _buildAgentCard(AgentProfile agent) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
@@ -133,37 +187,43 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
children: [
|
||||
// Agent image
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
|
||||
child: Image.asset(
|
||||
'assets/images/agent_profile_image.png',
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(15)),
|
||||
child: agent.avatar != null && agent.avatar!.isNotEmpty
|
||||
? Image.network(
|
||||
agent.avatar!,
|
||||
width: double.infinity,
|
||||
height: 192,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: double.infinity,
|
||||
height: 192,
|
||||
color: AppColors.gradientStart,
|
||||
child: const Icon(Icons.person, size: 60, color: AppColors.primaryDark),
|
||||
errorBuilder: (_, __, ___) => _buildAvatarPlaceholder(),
|
||||
)
|
||||
: _buildAvatarPlaceholder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Name and badges row
|
||||
// Name and rating row
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Arjun Mehta',
|
||||
style: TextStyle(
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
agent.fullName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (agent.isVerified) ...[
|
||||
const SizedBox(width: 8),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/verified_badge_icon.svg',
|
||||
@@ -175,7 +235,11 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (agent.rating != null) ...[
|
||||
SvgPicture.asset(
|
||||
'assets/icons/star_rating_icon.svg',
|
||||
width: 16,
|
||||
@@ -187,9 +251,9 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'4.9 Rating',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
'${agent.rating!.toStringAsFixed(1)} Rating',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
@@ -197,12 +261,15 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Verified and Location
|
||||
const Text(
|
||||
'"Verified local agent"',
|
||||
style: TextStyle(
|
||||
|
||||
// Verified text
|
||||
if (agent.isVerified)
|
||||
Text(
|
||||
'"Verified local ${agent.agentType?.name.toLowerCase() ?? 'agent'}"',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -210,7 +277,9 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Location
|
||||
if (agent.location.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
@@ -234,19 +303,24 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'San Francisco, CA',
|
||||
style: TextStyle(
|
||||
Expanded(
|
||||
child: Text(
|
||||
agent.location,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Expertise
|
||||
if (agent.agentType != null)
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
@@ -259,37 +333,40 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'(Residential Property Expert)',
|
||||
style: TextStyle(
|
||||
Expanded(
|
||||
child: Text(
|
||||
'(${agent.agentType!.name})',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Tags
|
||||
|
||||
// Tags from specializations
|
||||
if (agent.specializations.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildTag('Residential'),
|
||||
_buildTag('Rental'),
|
||||
_buildTag('Commercial'),
|
||||
_buildTag('Inspection'),
|
||||
_buildTag('Land'),
|
||||
_buildTag('Rental'),
|
||||
],
|
||||
children: agent.specializations
|
||||
.take(6)
|
||||
.map((tag) => _buildTag(tag))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Spacer(),
|
||||
|
||||
// Experience
|
||||
if (agent.experienceText.isNotEmpty)
|
||||
RichText(
|
||||
text: const TextSpan(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
const TextSpan(
|
||||
text: 'Experience: ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
@@ -299,8 +376,8 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: '10+ years in the real estate industry.',
|
||||
style: TextStyle(
|
||||
text: agent.experienceText,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
@@ -313,11 +390,21 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarPlaceholder() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 192,
|
||||
color: AppColors.gradientStart,
|
||||
child: const Icon(Icons.person, size: 60, color: AppColors.primaryDark),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(String label) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
@@ -337,7 +424,12 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressIndicator() {
|
||||
Widget _buildProgressIndicator(int totalCards) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final indicatorWidth = totalCards > 0
|
||||
? constraints.maxWidth / totalCards
|
||||
: 103.0;
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
@@ -350,7 +442,7 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
Container(
|
||||
height: 5,
|
||||
width: 103,
|
||||
width: indicatorWidth.clamp(40.0, 150.0),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryDark,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
@@ -358,6 +450,72 @@ class TopProfessionalsSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(WidgetRef ref, String error) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.accentOrange,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
ref.read(topProfessionalsProvider.notifier).refresh(),
|
||||
child: const Text(
|
||||
'Retry',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(String activeTab) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
activeTab == 'agents' ? Icons.people_outline : Icons.account_balance_outlined,
|
||||
color: AppColors.hintText,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No ${activeTab == 'agents' ? 'agents' : 'lenders'} found',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSeeAllAgentsCta() {
|
||||
|
||||
Reference in New Issue
Block a user