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 = []; // Suppress 401 errors during logout to prevent SnackBar flashing static bool suppressAuthErrors = false; // 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 // During logout, silently swallow ALL errors if (suppressAuthErrors) { handler.reject(error); return; } 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 _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; 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 _queueRequest(RequestOptions requestOptions) { final completer = Completer(); _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 _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.authLogout, ApiConstants.authForgotPassword, ApiConstants.authResetPassword, ApiConstants.authVerifyEmail, ApiConstants.twoFactorVerify, ApiConstants.twoFactorVerifyBackup, ]; 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) { final message = data['message'] is List ? (data['message'] as List).join(', ') : data['message']?.toString() ?? 'An error occurred'; final errors = (data['errors'] as List?) ?.map( (e) => FieldError.fromJson(e as Map)) .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 completer; final RequestOptions requestOptions; _QueuedRequest({ required this.completer, required this.requestOptions, }); }