2026-02-24 03:28:13 +05:30
|
|
|
import 'dart:async';
|
2026-04-10 17:35:59 +05:30
|
|
|
import 'dart:convert';
|
2026-02-24 03:28:13 +05:30
|
|
|
|
2026-02-23 19:23:30 +05:30
|
|
|
import 'package:dio/dio.dart';
|
|
|
|
|
import 'package:real_estate_mobile/config/app_config.dart';
|
2026-02-24 03:28:13 +05:30
|
|
|
import 'package:real_estate_mobile/core/constants/api_constants.dart';
|
2026-02-23 19:23:30 +05:30
|
|
|
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;
|
|
|
|
|
|
2026-02-24 03:28:13 +05:30
|
|
|
// Token refresh state
|
|
|
|
|
bool _isRefreshing = false;
|
|
|
|
|
final List<_QueuedRequest> _failedQueue = [];
|
|
|
|
|
|
2026-03-27 06:32:46 +05:30
|
|
|
// Suppress 401 errors during logout to prevent SnackBar flashing
|
|
|
|
|
static bool suppressAuthErrors = false;
|
|
|
|
|
|
2026-02-24 03:28:13 +05:30
|
|
|
// Callback to notify app of forced logout
|
|
|
|
|
static void Function()? onForceLogout;
|
|
|
|
|
|
2026-02-23 19:23:30 +05:30
|
|
|
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(
|
2026-02-24 03:28:13 +05:30
|
|
|
onError: (error, handler) async {
|
|
|
|
|
// Handle 401 — attempt token refresh
|
2026-03-31 06:04:30 +05:30
|
|
|
// During logout, silently swallow ALL errors
|
|
|
|
|
if (suppressAuthErrors) {
|
|
|
|
|
handler.reject(error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 03:28:13 +05:30
|
|
|
if (error.response?.statusCode == 401) {
|
2026-03-27 06:32:46 +05:30
|
|
|
|
2026-02-24 03:28:13 +05:30
|
|
|
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,
|
2026-02-23 19:23:30 +05:30
|
|
|
),
|
2026-02-24 03:28:13 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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));
|
2026-02-23 19:23:30 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 03:28:13 +05:30
|
|
|
// 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,
|
2026-03-15 00:35:39 +05:30
|
|
|
ApiConstants.authLogout,
|
2026-02-24 03:28:13 +05:30
|
|
|
ApiConstants.authForgotPassword,
|
|
|
|
|
ApiConstants.authResetPassword,
|
|
|
|
|
ApiConstants.authVerifyEmail,
|
2026-03-15 00:41:31 +05:30
|
|
|
ApiConstants.twoFactorVerify,
|
|
|
|
|
ApiConstants.twoFactorVerifyBackup,
|
2026-02-24 03:28:13 +05:30
|
|
|
];
|
|
|
|
|
return authPaths.any((path) => url.contains(path));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Wrap a DioException with parsed error data.
|
|
|
|
|
DioException _wrapError(DioException error) {
|
|
|
|
|
final response = error.response;
|
2026-04-09 05:22:11 +05:30
|
|
|
|
|
|
|
|
// 1. Try to parse a structured error from the JSON response body
|
2026-02-24 03:28:13 +05:30
|
|
|
if (response != null) {
|
2026-04-10 17:35:59 +05:30
|
|
|
// response.data can be Map (already decoded), String (raw JSON), or null.
|
|
|
|
|
Map<String, dynamic>? dataMap;
|
|
|
|
|
if (response.data is Map<String, dynamic>) {
|
|
|
|
|
dataMap = response.data as Map<String, dynamic>;
|
|
|
|
|
} else if (response.data is Map) {
|
|
|
|
|
dataMap = Map<String, dynamic>.from(response.data as Map);
|
|
|
|
|
} else if (response.data is String) {
|
|
|
|
|
try {
|
|
|
|
|
final decoded = _tryDecodeJson(response.data as String);
|
|
|
|
|
if (decoded is Map<String, dynamic>) dataMap = decoded;
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dataMap != null) {
|
|
|
|
|
final rawMessage = dataMap['message'] ?? dataMap['error'];
|
2026-04-09 05:22:11 +05:30
|
|
|
final message = rawMessage is List
|
|
|
|
|
? (rawMessage).join(', ')
|
|
|
|
|
: rawMessage?.toString();
|
|
|
|
|
|
|
|
|
|
if (message != null && message.isNotEmpty) {
|
2026-04-10 17:35:59 +05:30
|
|
|
final errors = (dataMap['errors'] as List<dynamic>?)
|
2026-04-09 05:22:11 +05:30
|
|
|
?.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,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-24 03:28:13 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 05:22:11 +05:30
|
|
|
// 2. Fallback — build a user-friendly message from the status code or
|
|
|
|
|
// Dio exception type. NEVER use `error.message` because Dio emits a
|
|
|
|
|
// giant technical blob ("This exception was thrown because the
|
|
|
|
|
// response has a status code of 401 and RequestOptions.validateStatus
|
|
|
|
|
// was configured to throw...") which is useless to end users.
|
2026-02-24 03:28:13 +05:30
|
|
|
return DioException(
|
|
|
|
|
requestOptions: error.requestOptions,
|
|
|
|
|
response: error.response,
|
|
|
|
|
error: ApiException(
|
2026-04-09 05:22:11 +05:30
|
|
|
message: _friendlyMessageFor(error),
|
2026-02-24 03:28:13 +05:30
|
|
|
statusCode: response?.statusCode,
|
|
|
|
|
),
|
2026-02-23 19:23:30 +05:30
|
|
|
);
|
|
|
|
|
}
|
2026-04-09 05:22:11 +05:30
|
|
|
|
2026-04-10 17:35:59 +05:30
|
|
|
dynamic _tryDecodeJson(String raw) {
|
|
|
|
|
try {
|
|
|
|
|
return jsonDecode(raw);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 05:22:11 +05:30
|
|
|
String _friendlyMessageFor(DioException error) {
|
|
|
|
|
// Network-level failures take priority over status codes
|
|
|
|
|
switch (error.type) {
|
|
|
|
|
case DioExceptionType.connectionTimeout:
|
|
|
|
|
case DioExceptionType.sendTimeout:
|
|
|
|
|
case DioExceptionType.receiveTimeout:
|
|
|
|
|
return 'The request timed out. Please check your connection and try again.';
|
|
|
|
|
case DioExceptionType.connectionError:
|
|
|
|
|
return 'Unable to reach the server. Please check your internet connection.';
|
|
|
|
|
case DioExceptionType.cancel:
|
|
|
|
|
return 'Request was cancelled.';
|
|
|
|
|
case DioExceptionType.badCertificate:
|
|
|
|
|
return 'Secure connection failed. Please try again later.';
|
|
|
|
|
case DioExceptionType.badResponse:
|
|
|
|
|
case DioExceptionType.unknown:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Status-code-based fallback
|
|
|
|
|
final code = error.response?.statusCode;
|
|
|
|
|
switch (code) {
|
|
|
|
|
case 400:
|
|
|
|
|
return 'The request was invalid. Please check your input.';
|
|
|
|
|
case 401:
|
|
|
|
|
return 'Invalid email or password.';
|
|
|
|
|
case 403:
|
|
|
|
|
return 'You don\'t have permission to perform this action.';
|
|
|
|
|
case 404:
|
|
|
|
|
return 'The requested resource was not found.';
|
|
|
|
|
case 409:
|
|
|
|
|
return 'This action conflicts with existing data.';
|
|
|
|
|
case 422:
|
|
|
|
|
return 'Some fields are invalid. Please check your input.';
|
|
|
|
|
case 429:
|
|
|
|
|
return 'Too many attempts. Please wait a moment and try again.';
|
|
|
|
|
case 500:
|
|
|
|
|
case 502:
|
|
|
|
|
case 503:
|
|
|
|
|
case 504:
|
|
|
|
|
return 'Server is temporarily unavailable. Please try again shortly.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'Something went wrong. Please try again.';
|
|
|
|
|
}
|
2026-02-23 19:23:30 +05:30
|
|
|
}
|
2026-02-24 03:28:13 +05:30
|
|
|
|
|
|
|
|
class _QueuedRequest {
|
|
|
|
|
final Completer<Response> completer;
|
|
|
|
|
final RequestOptions requestOptions;
|
|
|
|
|
|
|
|
|
|
_QueuedRequest({
|
|
|
|
|
required this.completer,
|
|
|
|
|
required this.requestOptions,
|
|
|
|
|
});
|
|
|
|
|
}
|