import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../localPreference/local_preference.dart'; import '../networkApiServices/api_urls.dart'; class NetworkApiService { static final NetworkApiService _instance = NetworkApiService._internal(); late Dio _dio; late Dio _tokenDio; // ✅ Separate Dio for token refresh (no interceptors) factory NetworkApiService() { return _instance; } NetworkApiService._internal() { // ================= MAIN DIO ================= _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 60), receiveTimeout: const Duration(seconds: 60), headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, ), ); // ================= TOKEN DIO (No interceptors — used only for refresh) ================= _tokenDio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(seconds: 30), headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, ), ); // ================= 1. RETRY INTERCEPTOR ================= _dio.interceptors.add( InterceptorsWrapper( onError: (err, handler) async { final options = err.requestOptions; const maxRetries = 2; final currentRetry = options.extra['retry'] as int? ?? 0; final shouldRetry = currentRetry < maxRetries && (err.type == DioExceptionType.connectionTimeout || err.type == DioExceptionType.sendTimeout || err.type == DioExceptionType.receiveTimeout); if (shouldRetry) { if (kDebugMode) { print( '🔁 Retrying request (${currentRetry + 1}/$maxRetries) => ${options.uri}', ); } options.extra['retry'] = currentRetry + 1; try { final response = await _dio.fetch(options); return handler.resolve(response); } on DioException catch (e) { return handler.reject(e); } } return handler.next(err); }, ), ); // ================= 2. MAIN INTERCEPTOR (Auth + Token Refresh) ================= _dio.interceptors.add( QueuedInterceptorsWrapper( onRequest: (options, handler) async { final token = await LocalPreference.getAccessToken(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); }, onResponse: (response, handler) { handler.next(response); }, onError: (error, handler) async { if (error.response?.statusCode == 401) { final requestOptions = error.requestOptions; try { final refreshed = await _refreshToken(); if (refreshed) { final newToken = await LocalPreference.getAccessToken(); requestOptions.headers['Authorization'] = 'Bearer $newToken'; // ✅ Retry original request with new token final response = await _dio.fetch(requestOptions); return handler.resolve(response); } else { await _forceLogout(); return handler.reject(error); } } catch (e) { if (kDebugMode) print('❌ Error during token refresh flow: $e'); await _forceLogout(); return handler.reject(error); } } handler.next(error); }, ), ); // ================= 3. LOGGING INTERCEPTOR ================= if (kDebugMode) { _dio.interceptors.add( LogInterceptor( request: true, requestHeader: true, requestBody: true, responseBody: true, responseHeader: false, error: true, logPrint: (log) => print('📡 $log'), ), ); } } // ================= GET ================= Future getApi({ required String url, Map? queryParameters, Options? options, CancelToken? cancelToken, }) async { try { return await _dio.get( url, queryParameters: queryParameters, options: options, cancelToken: cancelToken, ); } on DioException catch (e) { throw _handleError(e); } } // ================= POST ================= Future postApi({ required String url, dynamic data, Map? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onSendProgress, }) async { try { return await _dio.post( url, data: data, queryParameters: queryParameters, options: options, cancelToken: cancelToken, onSendProgress: onSendProgress, ); } on DioException catch (e) { throw _handleError(e); } } // ================= PUT ================= Future putApi({ required String url, dynamic data, Map? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onSendProgress, }) async { try { return await _dio.put( url, data: data, queryParameters: queryParameters, options: options, cancelToken: cancelToken, onSendProgress: onSendProgress, ); } on DioException catch (e) { throw _handleError(e); } } // ================= DELETE ================= Future deleteApi({ required String url, dynamic data, Map? queryParameters, Options? options, CancelToken? cancelToken, }) async { try { return await _dio.delete( url, data: data, queryParameters: queryParameters, options: options, cancelToken: cancelToken, ); } on DioException catch (e) { throw _handleError(e); } } // ================= REFRESH TOKEN ================= // ✅ Uses _tokenDio (no interceptors) to avoid QueuedInterceptor deadlock Future _refreshToken() async { try { final refreshToken = await LocalPreference.getRefreshToken(); if (kDebugMode) print('🔍 Refresh token from storage: $refreshToken'); if (refreshToken == null || refreshToken.isEmpty) { if (kDebugMode) print('❌ No refresh token found'); return false; } if (kDebugMode) print('🔄 Attempting token refresh...'); final response = await _tokenDio.post( ApiUrls.refreshToken, data: '', // ✅ Empty body — server reads token from Cookie header options: Options( headers: { // ✅ Manually inject refresh token as cookie header 'Cookie': 'refresh_token=$refreshToken', }, validateStatus: (status) => status != null && status < 500, ), ); if (kDebugMode) { print("🔄 Refresh response status: ${response.statusCode}"); print("✅ REFRESH RESPONSE => ${response.data}"); // ✅ ADD THESE to see full response details print("📋 Response Headers: ${response.headers.map}"); print("📋 Full Response Data: ${response.data.toString()}"); print("📋 Request Headers Sent: ${response.requestOptions.headers}"); // ← This shows what was actually sent } if (response.statusCode != 200 && response.statusCode != 201) { if (kDebugMode) print('❌ Refresh failed with status: ${response.statusCode}'); return false; } final newAccessToken = response.data['accessToken']; if (newAccessToken == null || (newAccessToken as String).isEmpty) { if (kDebugMode) print('❌ Access token missing in refresh response'); return false; } await LocalPreference.setAccessToken(newAccessToken); if (kDebugMode) print('✅ Token refreshed successfully'); return true; } on DioException catch (e) { if (kDebugMode) print('❌ Refresh token DioException: ${e.message}'); return false; } catch (e) { if (kDebugMode) print('❌ Refresh token unexpected error: $e'); return false; } } // ================= FORCE LOGOUT ================= Future _forceLogout() async { if (kDebugMode) print('🚪 Force logout triggered'); await LocalPreference.clearTokens(); await LocalPreference.setLogin(false); await LocalPreference.resetOnboarding(); // TODO: navigate to login screen } // ================= ERROR HANDLER ================= String _handleError(DioException error) { switch (error.type) { case DioExceptionType.connectionTimeout: return "Connection timeout. Please try again."; case DioExceptionType.sendTimeout: return "Send timeout. Please try again."; case DioExceptionType.receiveTimeout: return "Receive timeout. Please try again."; case DioExceptionType.badCertificate: return "Bad certificate."; case DioExceptionType.badResponse: try { final responseData = error.response?.data; if (responseData is Map) { return responseData['message'] ?? responseData['error'] ?? "Invalid status code: ${error.response?.statusCode}"; } if (responseData is String) { return responseData.isNotEmpty ? responseData : "Invalid status code: ${error.response?.statusCode}"; } return "Invalid status code: ${error.response?.statusCode}"; } catch (_) { return "Invalid status code: ${error.response?.statusCode}"; } case DioExceptionType.cancel: return "Request was cancelled."; case DioExceptionType.connectionError: return "No internet connection."; case DioExceptionType.unknown: return "Something went wrong. Please try again."; } } // ================= UPDATE HEADERS ================= void updateHeaders(Map headers) { _dio.options.headers.addAll(headers); } }