feat: Implement dynamic top professionals section with agent/lender tabs using Riverpod for state management.
This commit is contained in:
@@ -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,46 +57,209 @@ class ApiClient {
|
||||
|
||||
InterceptorsWrapper _errorInterceptor() {
|
||||
return InterceptorsWrapper(
|
||||
onError: (error, handler) {
|
||||
final response = error.response;
|
||||
if (response != null) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final message = data['message'] is List
|
||||
? (data['message'] as List).join(', ')
|
||||
: data['message']?.toString() ?? 'An error occurred';
|
||||
onError: (error, handler) async {
|
||||
// Handle 401 — attempt token refresh
|
||||
if (error.response?.statusCode == 401) {
|
||||
final requestUrl = error.requestOptions.path;
|
||||
|
||||
final errors = (data['errors'] as List<dynamic>?)
|
||||
?.map((e) => FieldError.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
// Don't try to refresh if this was the refresh request itself
|
||||
if (requestUrl.contains(ApiConstants.authRefresh)) {
|
||||
await _forceLogout();
|
||||
handler.reject(_wrapError(error));
|
||||
return;
|
||||
}
|
||||
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
error: ApiException(
|
||||
message: message,
|
||||
statusCode: response.statusCode,
|
||||
fieldErrors: errors,
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
error: ApiException(
|
||||
message: error.message ?? 'Network error occurred',
|
||||
statusCode: response?.statusCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
// 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;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final message = data['message'] is List
|
||||
? (data['message'] as List).join(', ')
|
||||
: data['message']?.toString() ?? 'An error occurred';
|
||||
|
||||
final errors = (data['errors'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => FieldError.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
return DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
error: ApiException(
|
||||
message: message,
|
||||
statusCode: response.statusCode,
|
||||
fieldErrors: errors,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user