256 lines
7.9 KiB
Dart
256 lines
7.9 KiB
Dart
// 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<String, String> _defaultHeaders(String token) => {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $token',
|
|
};
|
|
|
|
/// Ensures valid token with proactive refresh
|
|
Future<String?> _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<bool> _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<http.Response?> _execute(
|
|
String method,
|
|
String endpoint, {
|
|
Map<String, String>? queryParams,
|
|
Object? body,
|
|
Map<String, String>? 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<T?> get<T>(
|
|
String endpoint, {
|
|
Map<String, String>? queryParams,
|
|
bool fullResponse = false,
|
|
}) async {
|
|
final response = await _execute('GET', endpoint, queryParams: queryParams);
|
|
return response != null
|
|
? _parseResponse(response,
|
|
endpoint: endpoint, fullResponse: fullResponse)
|
|
: null;
|
|
}
|
|
|
|
Future<T?> post<T>(
|
|
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<T?> put<T>(
|
|
String endpoint,
|
|
Object? body, {
|
|
Map<String, String>? 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<T?> patch<T>(
|
|
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<T?> delete<T>(
|
|
String endpoint, {
|
|
Map<String, String>? 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();
|
|
}
|
|
}
|