Files
mobile-app/lib/core/network/api_client.dart

266 lines
7.6 KiB
Dart
Raw Normal View History

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';
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(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
_dio.interceptors.addAll([
_authInterceptor(),
_errorInterceptor(),
]);
}
static ApiClient get instance {
_instance ??= ApiClient._();
return _instance!;
}
Dio get dio => _dio;
InterceptorsWrapper _authInterceptor() {
return InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await SecureStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
);
}
InterceptorsWrapper _errorInterceptor() {
return InterceptorsWrapper(
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;
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,
});
}