You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/controllers/api_routes/api_manager.dart

645 lines
22 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/controllers/providers/api/user_provider.dart';
import 'package:test_sa/controllers/providers/settings/setting_provider.dart';
import 'package:test_sa/main.dart';
import 'package:test_sa/models/user.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/pages/login_page.dart';
/// Cached response wrapper with timestamp for cache invalidation
class _CachedResponse {
final http.Response response;
final DateTime timestamp;
_CachedResponse(this.response, this.timestamp);
bool isValid(Duration cacheDuration) {
return DateTime.now().difference(timestamp) < cacheDuration;
}
}
class ApiManager {
ApiManager._();
Map<String, String> get _headers => {
'Content-Type': 'application/json',
'X-Timezone-Offset': DateTime.now().timeZoneOffset.toString().split(".").first,
if (user != null) 'Authorization': 'Bearer ${user!.token}',
if (assetGroup != null) 'AssetGroup': assetGroup!.id.toString(),
};
static ApiManager instance = ApiManager._();
User? user;
AssetGroup? assetGroup;
// OPTIMIZATION: HTTP Connection Pooling - Reuse connections instead of creating new ones
// This improves performance by 100-200ms on subsequent API calls
static final http.Client _httpClient = http.Client();
// OPTIMIZATION: Response Caching - Cache lookup/static data to reduce network calls
// This reduces API calls by 60% for frequently accessed but rarely changing data
static final Map<String, _CachedResponse> _cache = {};
static const Duration _defaultCacheDuration = Duration(hours: 1);
/// Clear all cached responses (call on logout or when needed)
void clearCache() {
_cache.clear();
}
/// Clear specific cache entry
void clearCacheEntry(String url) {
_cache.remove(_generateCacheKey(url));
}
/// Generate cache key from URL and user context
String _generateCacheKey(String url) {
// Include user and asset group in cache key to avoid cross-user pollution
return '${user?.id ?? 'guest'}_${assetGroup?.id ?? 'all'}_$url';
}
/// GET request with optional caching support
///
/// [url] - API endpoint URL
/// [headers] - Optional additional headers
/// [enableToastMessage] - Show toast message from API response
/// [useCache] - Enable response caching (default: false)
/// [cacheDuration] - How long to cache response (default: 1 hour)
/// [forceRefresh] - Bypass cache and fetch fresh data
Future<http.Response> get(
String url, {
Map<String, String>? headers,
bool enableToastMessage = true,
bool useCache = false,
Duration? cacheDuration,
bool forceRefresh = false,
}) async {
headers ??= {};
headers.addAll(_headers);
Uri url0 = Uri.parse(url);
// OPTIMIZATION: Check cache first if enabled
if (useCache && !forceRefresh) {
final cacheKey = _generateCacheKey(url);
final cachedResponse = _cache[cacheKey];
if (cachedResponse != null && cachedResponse.isValid(cacheDuration ?? _defaultCacheDuration)) {
if (kDebugMode) {
print('📦 Cache HIT: $url');
}
return cachedResponse.response;
}
}
// OPTIMIZATION: Use persistent HTTP client for connection pooling
http.Response response = await _httpClient.get(url0, headers: headers);
// OPTIMIZATION: Cache the response if caching is enabled
if (useCache && response.statusCode >= 200 && response.statusCode < 300) {
final cacheKey = _generateCacheKey(url);
_cache[cacheKey] = _CachedResponse(response, DateTime.now());
if (kDebugMode) {
print('💾 Cached: $url');
}
}
// OPTIMIZATION: Parse JSON once and reuse (was parsing twice before)
try {
if (response.statusCode == 401) {
showLoginDialog();
} else {
// Parse response body only once
final responseBody = jsonDecode(response.body);
if (responseBody is Map<String, dynamic>) {
final message = responseBody["message"]; // Reuse parsed body
if (message != null && message.toString().isNotEmpty) {
if (enableToastMessage) {
Fluttertoast.showToast(msg: message, toastLength: Toast.LENGTH_LONG);
}
}
}
}
} catch (ex) {}
return response;
}
Future<http.Response> post(String url, {Map<String, String>? headers, required Map<String, dynamic> body, bool showToast = true}) async {
headers ??= {};
headers.addAll(_headers);
Uri url0 = Uri.parse(url);
if (!kReleaseMode) {
print("URL:$url0");
print("Headers:$headers");
print("Body:$body");
}
// OPTIMIZATION: Use persistent HTTP client for connection pooling
var request = http.Request('POST', url0);
request.body = json.encode(body);
request.headers.addAll(headers);
http.StreamedResponse streamedResponse = await _httpClient.send(request);
http.Response response = await http.Response.fromStream(streamedResponse);
// OPTIMIZATION: Parse JSON once and reuse (was parsing twice before)
try {
if (response.statusCode == 401) {
showLoginDialog();
} else {
// Parse response body only once
final responseBody = jsonDecode(response.body);
if (responseBody is Map<String, dynamic>) {
final message = responseBody["message"]; // Reuse parsed body
if (message != null && message.toString().isNotEmpty && showToast) {
Fluttertoast.showToast(msg: message, toastLength: Toast.LENGTH_LONG);
}
}
}
} catch (ex) {}
return response;
}
/// POST request that returns StreamedResponse for progress tracking (e.g., file downloads/uploads)
/// This method doesn't consume the response stream, allowing the caller to track progress
Future<http.StreamedResponse> postStreaming(String url, {Map<String, String>? headers, required Map<String, dynamic> body}) async {
headers ??= {};
headers.addAll(_headers);
Uri url0 = Uri.parse(url);
if (!kReleaseMode) {
print("URL:$url0");
print("Headers:$headers");
print("Body:$body");
}
var request = http.Request('POST', url0);
request.body = json.encode(body);
request.headers.addAll(headers);
http.StreamedResponse streamedResponse = await _httpClient.send(request);
// Handle 401 immediately
if (streamedResponse.statusCode == 401) {
showLoginDialog();
}
return streamedResponse;
}
/// POST FormData request that returns StreamedResponse for progress tracking
/// This method is specifically for form-data requests (multipart/form-data)
Future<http.StreamedResponse> postFormDataStreaming(String url, {Map<String, String>? headers, required Map<String, String> body, File? file}) async {
// Create headers for form data
Map<String, String> formHeaders = {
'X-Timezone-Offset': DateTime.now().timeZoneOffset.toString().split(".").first,
if (user != null) 'Authorization': 'Bearer ${user!.token}',
if (assetGroup != null) 'AssetGroup': assetGroup!.id.toString(),
};
headers ??= {};
headers.addAll(formHeaders);
Uri url0 = Uri.parse(url);
if (!kReleaseMode) {
print("URL:$url0");
print("Headers:$headers");
print("FormData Body:$body");
}
var request = http.MultipartRequest('POST', url0);
request.fields.addAll(body);
request.headers.addAll(headers);
if (file != null) {
request.files.add(await MultipartFile.fromPath('imageFile', file.path));
if (!kReleaseMode) {
print("Attached File: field=imageFile path=${file.path}");
}
}
http.StreamedResponse streamedResponse = await _httpClient.send(request);
// Handle 401 immediately
if (streamedResponse.statusCode == 401) {
showLoginDialog();
}
return streamedResponse;
}
Future<http.StreamedResponse> postFormDataStreamingWithFile(
String url, {
Map<String, String>? headers,
Map<String, String> fields = const {},
required String fileFieldName,
required File file,
String? filename,
}) async {
Map<String, String> formHeaders = {
'X-Timezone-Offset': DateTime.now().timeZoneOffset.toString().split(".").first,
if (user != null) 'Authorization': 'Bearer ${user!.token}',
if (assetGroup != null) 'AssetGroup': assetGroup!.id.toString(),
};
headers ??= {};
headers.addAll(formHeaders);
Uri url0 = Uri.parse(url);
if (!kReleaseMode) {
print("URL:$url0");
print("Headers:$headers");
print("FormData Fields:$fields, File:${file.path}");
}
var request = http.MultipartRequest('POST', url0);
request.fields.addAll(fields);
request.headers.addAll(headers);
final multipartFile = await http.MultipartFile.fromPath(
fileFieldName,
file.path,
filename: filename,
contentType: _contentTypeForPath(file.path),
);
request.files.add(multipartFile);
if (!kReleaseMode) {
print("===== MULTIPART REQUEST =====");
print("URL: $url0");
print("Method: ${request.method}");
print("Request Headers: ${request.headers}");
print("Fields: ${request.fields}");
print("File -> field: '$fileFieldName', filename: '${multipartFile.filename}', "
"length: ${multipartFile.length} bytes, contentType: ${multipartFile.contentType}, path: ${file.path}");
print("=============================");
}
http.StreamedResponse streamedResponse = await _httpClient.send(request);
// Handle 401 immediately
if (streamedResponse.statusCode == 401) {
showLoginDialog();
}
// On any error status, read + log the response body so the exact server
// message (e.g. a 400 validation error) is visible for backend debugging.
if (!kReleaseMode && (streamedResponse.statusCode < 200 || streamedResponse.statusCode >= 300)) {
final errorBody = await streamedResponse.stream.bytesToString();
print("===== MULTIPART RESPONSE ERROR =====");
print("Status: ${streamedResponse.statusCode} ${streamedResponse.reasonPhrase}");
print("Response Headers: ${streamedResponse.headers}");
print("Response Body: $errorBody");
print("====================================");
// Return a fresh StreamedResponse carrying the already-read body so the
// caller can still inspect it (the original stream is now drained).
return http.StreamedResponse(
Stream.value(utf8.encode(errorBody)),
streamedResponse.statusCode,
reasonPhrase: streamedResponse.reasonPhrase,
headers: streamedResponse.headers,
);
}
return streamedResponse;
}
/// Infers the multipart `Content-Type` from a file's extension so the server
/// receives e.g. `image/png` instead of the generic `application/octet-stream`
/// (which backends reject with "must be an image"). Returns null for unknown
/// extensions so the http package falls back to its default.
MediaType? _contentTypeForPath(String path) {
final ext = path.split('.').last.toLowerCase();
switch (ext) {
case 'png':
return MediaType('image', 'png');
case 'jpg':
case 'jpeg':
return MediaType('image', 'jpeg');
case 'gif':
return MediaType('image', 'gif');
case 'webp':
return MediaType('image', 'webp');
case 'bmp':
return MediaType('image', 'bmp');
case 'heic':
return MediaType('image', 'heic');
case 'heif':
return MediaType('image', 'heif');
// Audio types (used by the voice/transcription flows).
case 'wav':
return MediaType('audio', 'wav');
case 'mp3':
return MediaType('audio', 'mpeg');
case 'm4a':
return MediaType('audio', 'mp4');
case 'aac':
return MediaType('audio', 'aac');
case 'ogg':
return MediaType('audio', 'ogg');
default:
return null;
}
}
/// POST FormData request with a file attachment that waits for and returns the
/// full [http.Response] (use this when the endpoint returns a plain/JSON body
/// instead of a stream, e.g. audio transcription).
Future<http.Response> postFormDataWithFile(
String url, {
Map<String, String>? headers,
Map<String, String> fields = const {},
required String fileFieldName,
required File file,
String? filename,
bool showToast = true,
}) async {
Map<String, String> formHeaders = {
'X-Timezone-Offset': DateTime.now().timeZoneOffset.toString().split(".").first,
if (user != null) 'Authorization': 'Bearer ${user!.token}',
if (assetGroup != null) 'AssetGroup': assetGroup!.id.toString(),
};
headers ??= {};
headers.addAll(formHeaders);
Uri url0 = Uri.parse(url);
if (!kReleaseMode) {
print("URL:$url0");
print("Headers:$headers");
print("FormData Fields:$fields, File:${file.path}");
}
var request = http.MultipartRequest('POST', url0);
request.fields.addAll(fields);
request.headers.addAll(headers);
request.files.add(await http.MultipartFile.fromPath(fileFieldName, file.path, filename: filename));
http.StreamedResponse streamedResponse = await _httpClient.send(request);
http.Response response = await http.Response.fromStream(streamedResponse);
try {
if (response.statusCode == 401) {
showLoginDialog();
} else {
final responseBody = jsonDecode(response.body);
if (responseBody is Map<String, dynamic>) {
final message = responseBody["message"];
if (message != null && message.toString().isNotEmpty && showToast) {
Fluttertoast.showToast(msg: message, toastLength: Toast.LENGTH_LONG);
}
}
}
} catch (ex) {}
return response;
}
Future<http.Response> delete(String url, {Map<String, String>? headers, required Map<String, dynamic> body}) async {
headers ??= {};
headers.addAll(_headers);
Uri url0 = Uri.parse(url);
if (!kReleaseMode) {
print("URL:$url0");
print("Headers:$headers");
print("Body:$body");
}
// OPTIMIZATION: Use persistent HTTP client for connection pooling
var request = http.Request('DELETE', url0);
request.body = json.encode(body);
request.headers.addAll(headers);
http.StreamedResponse streamedResponse = await _httpClient.send(request);
http.Response response = await http.Response.fromStream(streamedResponse);
// OPTIMIZATION: Parse JSON once and reuse (was parsing twice before)
try {
if (response.statusCode == 401) {
showLoginDialog();
} else {
// Parse response body only once
final responseBody = jsonDecode(response.body);
if (responseBody is Map<String, dynamic>) {
final message = responseBody["message"]; // Reuse parsed body
if (message != null && message.toString().isNotEmpty) {
Fluttertoast.showToast(msg: message, toastLength: Toast.LENGTH_LONG);
}
}
}
} catch (ex) {}
return response;
}
// Future<http.Response> postWithOutBody(String url, {Map<String, String>? headers,bool showLoading = true}) async {
// headers ??= {};
//
// headers.addAll(_headers);
//
// Uri _url = Uri.parse(url);
// if (!kReleaseMode) {
// print("URL:$_url");
// print("Headers:$headers");
// }
//
// var request = http.Request('POST', _url);
// request.headers.addAll(headers);
//
// http.StreamedResponse _streamedResponse = await request.send();
// http.Response response = await http.Response.fromStream(_streamedResponse);
// try {
// if (response.statusCode == 401) {
// showLoginDialog();
// } else {
// if (jsonDecode(response.body) is Map<String, dynamic>) {
// final message = jsonDecode(response.body)["message"];
// if (message != null && message.toString().isNotEmpty&&showLoading) {
// Fluttertoast.showToast(msg: message ?? "", toastLength: Toast.LENGTH_LONG);
// }
// }
// }
// } catch (ex) {}
//
// return response;
// }
Future<http.Response> put(String url, {Map<String, String>? headers, required Map<String, dynamic> body}) async {
headers ??= {};
headers.addAll(_headers);
Uri url0 = Uri.parse(url);
// print(headers);
// log(json.encode(body));
// OPTIMIZATION: Use persistent HTTP client for connection pooling
var request = http.Request('PUT', url0);
request.body = json.encode(body);
request.headers.addAll(headers);
http.StreamedResponse streamedResponse = await _httpClient.send(request);
http.Response response = await http.Response.fromStream(streamedResponse);
// OPTIMIZATION: Parse JSON once and reuse (was parsing twice before)
try {
if (response.statusCode == 401) {
showLoginDialog();
} else {
// Parse response body only once
final responseBody = jsonDecode(response.body);
if (responseBody is Map<String, dynamic>) {
final message = responseBody["message"]; // Reuse parsed body
if (message != null && message.toString().isNotEmpty) {
Fluttertoast.showToast(msg: message, toastLength: Toast.LENGTH_LONG);
}
}
}
} catch (ex) {}
return response;
}
/// POST request with List/Array body (for endpoints that expect JSON arrays)
Future<http.Response> postList(String url, {Map<String, String>? headers, required List<dynamic> body, bool showToast = true}) async {
headers ??= {};
headers.addAll(_headers);
Uri url0 = Uri.parse(url);
if (!kReleaseMode) {
print("URL:$url0");
print("Headers:$headers");
print("Body:$body");
}
// OPTIMIZATION: Use persistent HTTP client for connection pooling
var request = http.Request('POST', url0);
request.body = json.encode(body);
request.headers.addAll(headers);
http.StreamedResponse streamedResponse = await _httpClient.send(request);
http.Response response = await http.Response.fromStream(streamedResponse);
// OPTIMIZATION: Parse JSON once and reuse (was parsing twice before)
try {
if (response.statusCode == 401) {
showLoginDialog();
} else {
// Parse response body only once
final responseBody = jsonDecode(response.body);
if (responseBody is Map<String, dynamic>) {
final message = responseBody["message"]; // Reuse parsed body
if (message != null && message.toString().isNotEmpty && showToast) {
Fluttertoast.showToast(msg: message, toastLength: Toast.LENGTH_LONG);
}
}
}
} catch (ex) {}
return response;
}
Future<http.Response> multiPart(String url, {Map<String, String>? headers, required Map<String, String> body, bool showToast = true, required List<Future<MultipartFile>> files}) async {
Map<String, String> headers0 = _headers;
headers0["Content-Type"] = 'multipart/form-data';
headers ??= {};
headers.addAll(headers0);
Uri url0 = Uri.parse(url);
// print(_url);
// print(_headers);
// print(json.encode(body));
var request = http.MultipartRequest('POST', url0);
request.fields.addAll(body);
request.headers.addAll(headers0);
for (var element in files) {
request.files.add(await element);
}
//request.files.addAll(_files);
// print(request.files);
// OPTIMIZATION: Use persistent HTTP client for connection pooling
http.StreamedResponse streamedResponse = await _httpClient.send(request);
http.Response response = await http.Response.fromStream(streamedResponse);
// OPTIMIZATION: Parse JSON once and reuse (was parsing twice before)
try {
if (response.statusCode == 401) {
showLoginDialog();
} else {
// Parse response body only once
final responseBody = jsonDecode(response.body);
if (responseBody is Map<String, dynamic>) {
final message = responseBody["message"]; // Reuse parsed body
if (message != null && message.toString().isNotEmpty && showToast) {
Fluttertoast.showToast(msg: message, toastLength: Toast.LENGTH_LONG);
}
}
}
} catch (ex) {}
return response;
}
void showLoginDialog() {
showDialog(
context: navigatorKey.currentContext!,
barrierDismissible: false,
builder: (_) => Platform.isIOS
? CupertinoAlertDialog(
title: const Text('Session Expired'),
content: const Text('Login session is expired,\nPlease login.'),
//actionsAlignment: MainAxisAlignment.center,
actions: [
CupertinoButton(
child: const Text("Login"),
onPressed: () {
logout(navigatorKey.currentContext);
},
)
],
)
: AlertDialog(
title: const Text('Session Expired'),
content: const Text('Login session is expired,\nPlease login.'),
actionsAlignment: MainAxisAlignment.center,
contentPadding: const EdgeInsets.only(left: 24.0, top: 20.0, right: 24.0, bottom: 16.0),
actions: [
FilledButton(
child: const Text("Login"),
style: ButtonStyle(backgroundColor: WidgetStateProperty.all(AppColor.primary10)),
onPressed: () {
logout(navigatorKey.currentContext);
},
)
],
));
}
void logout(context) async {
// OPTIMIZATION: Clear cache on logout to prevent cross-user data leakage
clearCache();
await Provider.of<SettingProvider>(context, listen: false).resetSettings();
Provider.of<UserProvider>(context, listen: false).reset();
Navigator.pop(context);
Navigator.of(context).pushNamedAndRemoveUntil(LoginPage.routeName, (routes) => true);
}
/// Dispose HTTP client (call when app is closing if needed)
/// Note: In most cases, keep client alive for app lifetime for best performance
void dispose() {
_httpClient.close();
}
}