// lib/helpers/services/http_client.dart import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/api_endpoints.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/utils/encryption_helper.dart'; /// Centralized HTTP client with automatic token management, encryption, /// and retry logic for OnFieldWork.com API communication. class HttpClient { static const Duration _timeout = Duration(seconds: 60); static const Duration _tokenRefreshThreshold = Duration(minutes: 2); final http.Client _client = http.Client(); bool _isRefreshing = false; /// Private constructor - use singleton instance HttpClient._(); static final HttpClient instance = HttpClient._(); /// Clean headers with JWT token Map _defaultHeaders(String token) => { 'Content-Type': 'application/json', 'Authorization': 'Bearer $token', }; /// Ensures valid token with proactive refresh Future _getValidToken() async { String? token = await LocalStorage.getJwtToken(); if (token == null) { logSafe("No JWT token available", level: LogLevel.error); await LocalStorage.logout(); return null; } try { if (JwtDecoder.isExpired(token) || JwtDecoder.getExpirationDate(token).difference(DateTime.now()) < _tokenRefreshThreshold) { logSafe("Token expired/expiring soon. Refreshing...", level: LogLevel.info); if (!await _refreshTokenIfPossible()) { logSafe("Token refresh failed. Logging out.", level: LogLevel.error); await LocalStorage.logout(); return null; } token = await LocalStorage.getJwtToken(); } } catch (e) { logSafe("Token validation failed: $e. Logging out.", level: LogLevel.error); await LocalStorage.logout(); return null; } return token; } /// Attempts token refresh with concurrency protection Future _refreshTokenIfPossible() async { if (_isRefreshing) return false; _isRefreshing = true; try { return await AuthService.refreshToken(); } finally { _isRefreshing = false; } } /// Unified response parser with decryption and validation dynamic _parseResponse( http.Response response, { required String endpoint, bool fullResponse = false, }) { final body = response.body.trim(); if (body.isEmpty && response.statusCode >= 200 && response.statusCode < 300) { logSafe("Empty response for $endpoint - returning default structure", level: LogLevel.info); return fullResponse ? {'success': true, 'data': []} : []; } final decryptedData = decryptResponse(body); if (decryptedData == null) { logSafe("❌ Decryption failed for $endpoint", level: LogLevel.error); return null; } final jsonData = decryptedData; if (response.statusCode >= 200 && response.statusCode < 300) { if (jsonData is Map && jsonData['success'] == true) { logSafe("✅ $endpoint: Success (${response.statusCode})", level: LogLevel.info); return fullResponse ? jsonData : jsonData['data']; } else if (jsonData is Map) { logSafe( "⚠️ $endpoint: API error - ${jsonData['message'] ?? 'Unknown error'}", level: LogLevel.warning); } } logSafe("❌ $endpoint: HTTP ${response.statusCode} - $jsonData", level: LogLevel.error); return null; } /// Generic request executor with 401 retry logic Future _execute( String method, String endpoint, { Map? queryParams, Object? body, Map? extraHeaders, bool hasRetried = false, }) async { final token = await _getValidToken(); if (token == null) return null; final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint").replace( queryParameters: (method == 'GET' || method == 'DELETE') ? queryParams : null); final headers = { ..._defaultHeaders(token), if (extraHeaders != null) ...extraHeaders, }; final requestBody = body != null ? jsonEncode(body) : null; logSafe( "📡 $method $uri${requestBody != null ? ' | Body: ${requestBody.length > 100 ? '${requestBody.substring(0, 100)}...' : requestBody}' : ''}", level: LogLevel.debug); try { final response = switch (method) { 'GET' => await _client.get(uri, headers: headers).timeout(_timeout), 'POST' => await _client .post(uri, headers: headers, body: requestBody) .timeout(_timeout), 'PUT' => await _client .put(uri, headers: headers, body: requestBody) .timeout(_timeout), 'PATCH' => await _client .patch(uri, headers: headers, body: requestBody) .timeout(_timeout), 'DELETE' => await _client.delete(uri, headers: headers).timeout(_timeout), _ => throw HttpException('Unsupported method: $method'), }; // Handle 401 with single retry if (response.statusCode == 401 && !hasRetried) { logSafe("🔄 401 detected for $endpoint - retrying with fresh token", level: LogLevel.warning); if (await _refreshTokenIfPossible()) { return await _execute(method, endpoint, queryParams: queryParams, body: body, extraHeaders: extraHeaders, hasRetried: true); } await LocalStorage.logout(); return null; } return response; } on SocketException catch (e) { logSafe("🌐 Network error for $endpoint: $e", level: LogLevel.error); return null; } catch (e, stackTrace) { logSafe("💥 HTTP $method error for $endpoint: $e\n$stackTrace", level: LogLevel.error); return null; } } // Public API - Clean and consistent Future get( String endpoint, { Map? queryParams, bool fullResponse = false, }) async { final response = await _execute('GET', endpoint, queryParams: queryParams); return response != null ? _parseResponse(response, endpoint: endpoint, fullResponse: fullResponse) : null; } Future post( String endpoint, Object? body, { bool fullResponse = false, }) async { final response = await _execute('POST', endpoint, body: body); return response != null ? _parseResponse(response, endpoint: endpoint, fullResponse: fullResponse) : null; } Future put( String endpoint, Object? body, { Map? extraHeaders, bool fullResponse = false, }) async { final response = await _execute('PUT', endpoint, body: body, extraHeaders: extraHeaders); return response != null ? _parseResponse(response, endpoint: endpoint, fullResponse: fullResponse) : null; } Future patch( String endpoint, Object? body, { bool fullResponse = false, }) async { final response = await _execute('PATCH', endpoint, body: body); return response != null ? _parseResponse(response, endpoint: endpoint, fullResponse: fullResponse) : null; } Future delete( String endpoint, { Map? queryParams, bool fullResponse = false, }) async { final response = await _execute('DELETE', endpoint, queryParams: queryParams); return response != null ? _parseResponse(response, endpoint: endpoint, fullResponse: fullResponse) : null; } /// Proper cleanup for long-lived instances void dispose() { _client.close(); } }