diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 896fe4a4..64074ff8 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/api/http_client_manager.dart'; import 'package:hmg_patient_app_new/core/api_consts.dart'; import 'package:hmg_patient_app_new/core/app_state.dart'; import 'package:hmg_patient_app_new/core/dependencies.dart'; @@ -13,6 +14,7 @@ import 'package:hmg_patient_app_new/generated/locale_keys.g.dart'; import 'package:hmg_patient_app_new/presentation/home/app_update_page.dart'; import 'package:hmg_patient_app_new/routes/app_routes.dart'; import 'package:hmg_patient_app_new/services/analytics/analytics_service.dart'; +import 'package:hmg_patient_app_new/services/app_lifecycle_service.dart'; import 'package:hmg_patient_app_new/services/navigation_service.dart'; import 'package:hmg_patient_app_new/widgets/routes/custom_page_route.dart'; import 'package:http/http.dart' as http; @@ -87,10 +89,14 @@ abstract class ApiClient { class ApiClientImp implements ApiClient { final _analytics = getIt(); final AppState _appState; + final HttpClientManager _httpClient; ApiClientImp({ required AppState appState, - }) : _appState = appState; + HttpClientManager? httpClient, + }) : _appState = appState, + _httpClient = httpClient ?? + HttpClientManager(lifecycleService: getIt()); @override post( @@ -242,7 +248,11 @@ class ApiClientImp implements ApiClient { http.Response response; try { - response = await http.post(Uri.parse(url.trim()), body: requestBody, headers: headers); + response = await _httpClient.post( + uri: Uri.parse(url.trim()), + body: requestBody, + headers: headers, + ); // debugPrint("response: ${response.body}", wrapWidth: 2048); } on SocketException catch (e) { final message = e.message.contains('Connection reset by peer') ? LocaleKeys.networkConnectionReset.tr() : LocaleKeys.networkErrorMessage.tr(); @@ -445,8 +455,8 @@ class ApiClientImp implements ApiClient { if (await Utils.checkConnection(bypassConnectionCheck: true)) { http.Response response; try { - response = await http.get( - Uri.parse(url.trim()), + response = await _httpClient.get( + uri: Uri.parse(url.trim()), headers: apiHeaders ?? {'Content-Type': 'application/json', 'Accept': 'application/json'}, ); } on SocketException catch (e) { diff --git a/lib/core/api/http_client_manager.dart b/lib/core/api/http_client_manager.dart new file mode 100644 index 00000000..d264283d --- /dev/null +++ b/lib/core/api/http_client_manager.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:hmg_patient_app_new/core/utils/utils.dart'; +import 'package:hmg_patient_app_new/services/app_lifecycle_service.dart'; +import 'package:http/http.dart' as http; + +/// HTTP Client Manager with automatic retry logic for transient network errors +/// Handles background/foreground transitions gracefully +class HttpClientManager { + final http.Client _client; + final AppLifecycleService _lifecycleService; + + static const Duration _defaultTimeout = Duration(seconds: 30); + static const int _maxRetries = 2; + + HttpClientManager({ + required AppLifecycleService lifecycleService, + http.Client? client, + }) : _lifecycleService = lifecycleService, + _client = client ?? http.Client(); + + /// Execute POST request with retry logic + Future post({ + required Uri uri, + Map? headers, + Object? body, + Duration? timeout, + int maxRetries = _maxRetries, + }) async { + return _executeWithRetry( + () => _client + .post(uri, headers: headers, body: body) + .timeout(timeout ?? _defaultTimeout), + endpoint: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.toString(), + maxRetries: maxRetries, + ); + } + + /// Execute GET request with retry logic + Future get({ + required Uri uri, + Map? headers, + Duration? timeout, + int maxRetries = _maxRetries, + }) async { + return _executeWithRetry( + () => _client + .get(uri, headers: headers) + .timeout(timeout ?? _defaultTimeout), + endpoint: uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.toString(), + maxRetries: maxRetries, + ); + } + + /// Core retry logic for HTTP requests + Future _executeWithRetry( + Future Function() request, { + required String endpoint, + int maxRetries = _maxRetries, + }) async { + int attempt = 0; + + while (attempt <= maxRetries) { + try { + // Wait for app to be in foreground before attempting request + await _waitForAppForeground(); + + // Check if app just came from background + if (_lifecycleService.lastResumedTime != null && attempt == 0) { + final timeSinceResume = DateTime.now().difference(_lifecycleService.lastResumedTime!); + if (timeSinceResume.inSeconds < 5) { + debugPrint('🔄 App recently resumed (${timeSinceResume.inSeconds}s ago), adding small delay before request...'); + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + return await request(); + + } on SocketException catch (e) { + if (attempt >= maxRetries) { + // If app is in background, wait for it to come back before throwing + if (_lifecycleService.isAppInBackground) { + debugPrint('⏸️ App in background, waiting to resume before final error for $endpoint'); + await _waitForAppForeground(); + } + rethrow; + } + await _handleRetry( + attempt: attempt++, + errorType: 'SocketException', + endpoint: endpoint, + errorDetails: e.message, + ); + } on http.ClientException catch (e) { + if (attempt >= maxRetries) { + // If app is in background, wait for it to come back before throwing + if (_lifecycleService.isAppInBackground) { + debugPrint('⏸️ App in background, waiting to resume before final error for $endpoint'); + await _waitForAppForeground(); + } + rethrow; + } + await _handleRetry( + attempt: attempt++, + errorType: 'ClientException', + endpoint: endpoint, + errorDetails: e.message, + ); + } on TimeoutException catch (e) { + // For timeout, only retry once + if (attempt >= 1) { + // If app is in background, wait for it to come back before throwing + if (_lifecycleService.isAppInBackground) { + debugPrint('⏸️ App in background, waiting to resume before final error for $endpoint'); + await _waitForAppForeground(); + } + rethrow; + } + await _handleRetry( + attempt: attempt++, + errorType: 'TimeoutException', + endpoint: endpoint, + errorDetails: e.message ?? 'Request timed out', + ); + } + } + + throw Exception('Max retries exceeded for $endpoint'); + } + + /// Wait for app to be in foreground before proceeding + Future _waitForAppForeground() async { + if (!_lifecycleService.isAppInBackground) { + return; // App is already in foreground + } + + debugPrint('⏸️ App is in background, pausing request until app resumes...'); + + // Wait for app to resume with a timeout + final completer = Completer(); + late StreamSubscription subscription; + + // Set up a listener for app state changes + subscription = _lifecycleService.appStateStream.listen((state) { + if (state == AppLifecycleState.resumed) { + if (!completer.isCompleted) { + debugPrint('✅ App resumed, continuing with request'); + completer.complete(); + } + } + }); + + // Also check current state in case it changed + if (_lifecycleService.currentState == AppLifecycleState.resumed) { + if (!completer.isCompleted) { + completer.complete(); + } + } + + try { + // Wait for app to resume with 60 second timeout + await completer.future.timeout( + const Duration(seconds: 60), + onTimeout: () { + debugPrint('⚠️ Timeout waiting for app to resume'); + }, + ); + } finally { + await subscription.cancel(); + } + + // Add small delay after resume to let things stabilize + await Future.delayed(const Duration(milliseconds: 300)); + } + + /// Handle retry delay with exponential backoff and validation + Future _handleRetry({ + required int attempt, + required String errorType, + required String endpoint, + required String errorDetails, + }) async { + // If app went to background during the error, wait for it to come back + if (_lifecycleService.isAppInBackground) { + debugPrint('⏸️ Error occurred while app in background, waiting for app to resume...'); + await _waitForAppForeground(); + } + + // Check network connectivity before retrying + final hasConnection = await Utils.checkConnection(bypassConnectionCheck: true); + if (!hasConnection) { + debugPrint('⚠️ No network connection available, waiting before retry...'); + // Wait a bit and check again instead of immediately throwing + await Future.delayed(const Duration(seconds: 2)); + final recheckConnection = await Utils.checkConnection(bypassConnectionCheck: true); + if (!recheckConnection) { + throw SocketException('No network connection available for retry'); + } + } + + // Exponential backoff: 300ms, 900ms, 2700ms + final delay = Duration(milliseconds: 300 * (1 << attempt)); + + debugPrint( + '🔄 Retry attempt ${attempt + 1}/$_maxRetries for $endpoint\n' + ' Error: $errorType - $errorDetails\n' + ' Waiting: ${delay.inMilliseconds}ms\n' + ' App State: ${_lifecycleService.currentState}' + ); + + await Future.delayed(delay); + } + + /// Close the HTTP client + void dispose() { + _client.close(); + } +} + + + + + diff --git a/lib/core/dependencies.dart b/lib/core/dependencies.dart index 3b9eefc0..1de302b7 100644 --- a/lib/core/dependencies.dart +++ b/lib/core/dependencies.dart @@ -68,6 +68,7 @@ import 'package:hmg_patient_app_new/features/weather/weather_repo.dart'; import 'package:hmg_patient_app_new/features/weather/weather_view_model.dart'; import 'package:hmg_patient_app_new/features/health_trackers/health_trackers_view_model.dart'; import 'package:hmg_patient_app_new/services/analytics/analytics_service.dart'; +import 'package:hmg_patient_app_new/services/app_lifecycle_service.dart'; import 'package:hmg_patient_app_new/services/cache_service.dart'; import 'package:hmg_patient_app_new/services/dialog_service.dart'; import 'package:hmg_patient_app_new/services/error_handler_service.dart'; @@ -140,6 +141,9 @@ class AppDependencies { loggerService: getIt(), )); + // App Lifecycle Service - must be registered before ApiClient + getIt.registerLazySingleton(() => AppLifecycleService()); + getIt.registerLazySingleton(() => ApiClientImp(appState: getIt())); getIt.registerLazySingleton( () => LocalAuthService(loggerService: getIt(), localAuth: getIt()), diff --git a/lib/main.dart b/lib/main.dart index 4aa20551..5999a04e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -49,6 +49,8 @@ import 'package:hmg_patient_app_new/features/water_monitor/water_monitor_view_mo import 'package:hmg_patient_app_new/presentation/health_calculators_and_converts/health_calculator_view_model.dart'; import 'package:hmg_patient_app_new/features/health_trackers/health_trackers_view_model.dart'; import 'package:hmg_patient_app_new/routes/app_routes.dart'; +import 'package:hmg_patient_app_new/services/analytics/analytics_service.dart'; +import 'package:hmg_patient_app_new/services/app_lifecycle_service.dart'; import 'package:hmg_patient_app_new/services/logger_service.dart'; import 'package:hmg_patient_app_new/services/navigation_service.dart'; import 'package:hmg_patient_app_new/theme/app_theme.dart'; @@ -123,6 +125,9 @@ Future callInitializations() async { HttpOverrides.global = MyHttpOverrides(); await callAppStateInitializations(); + // Initialize App Lifecycle Service to monitor background/foreground transitions + getIt.get().initialize(); + // Restore persisted dark-mode preference before the first frame. getIt.get().loadDarkMode(); } diff --git a/lib/presentation/lab/lab_result_via_clinic/LabResultList.dart b/lib/presentation/lab/lab_result_via_clinic/LabResultList.dart index 7bb0cf32..8aedd8a5 100644 --- a/lib/presentation/lab/lab_result_via_clinic/LabResultList.dart +++ b/lib/presentation/lab/lab_result_via_clinic/LabResultList.dart @@ -20,8 +20,9 @@ class LabResultList extends StatelessWidget { selector: (_, model) => model.mainLabResultsByHospitals, builder: (__, list, ___) { if (list.isEmpty && context.read().labSpecialResult.isEmpty) { - return Utils.getNoDataWidget(context, - noDataText: LocaleKeys.noLabResults.tr(context: context)); + // return Utils.getNoDataWidget(context, + // noDataText: LocaleKeys.noLabResults.tr(context: context)); + return Container(); } else { return ListView.builder( physics: NeverScrollableScrollPhysics(), diff --git a/lib/services/app_lifecycle_service.dart b/lib/services/app_lifecycle_service.dart new file mode 100644 index 00000000..4a0dd947 --- /dev/null +++ b/lib/services/app_lifecycle_service.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +/// Service to monitor and track app lifecycle state changes +/// This helps detect when app goes to background/foreground +class AppLifecycleService with WidgetsBindingObserver { + final _stateController = StreamController.broadcast(); + + /// Stream of app lifecycle state changes + Stream get appStateStream => _stateController.stream; + + AppLifecycleState _currentState = AppLifecycleState.resumed; + + /// Current app lifecycle state + AppLifecycleState get currentState => _currentState; + + /// Check if app is currently active/visible + bool get isAppActive => _currentState == AppLifecycleState.resumed; + + /// Check if app is in background or inactive + bool get isAppInBackground => + _currentState == AppLifecycleState.paused || + _currentState == AppLifecycleState.inactive || + _currentState == AppLifecycleState.detached; + + /// Last time app was resumed + DateTime? _lastResumedTime; + DateTime? get lastResumedTime => _lastResumedTime; + + /// Last time app went to background + DateTime? _lastPausedTime; + DateTime? get lastPausedTime => _lastPausedTime; + + /// Duration app has been in current state + Duration get timeInCurrentState { + final referenceTime = _currentState == AppLifecycleState.resumed + ? _lastResumedTime + : _lastPausedTime; + + if (referenceTime == null) return Duration.zero; + return DateTime.now().difference(referenceTime); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _currentState = state; + + // Track timing + if (state == AppLifecycleState.resumed) { + _lastResumedTime = DateTime.now(); + debugPrint('📱 App RESUMED'); + } else if (state == AppLifecycleState.paused) { + _lastPausedTime = DateTime.now(); + debugPrint('📱 App PAUSED/BACKGROUNDED'); + } else if (state == AppLifecycleState.inactive) { + debugPrint('📱 App INACTIVE'); + } else if (state == AppLifecycleState.detached) { + debugPrint('📱 App DETACHED'); + } + + _stateController.add(state); + } + + /// Initialize the lifecycle observer + void initialize() { + WidgetsBinding.instance.addObserver(this); + debugPrint('📱 AppLifecycleService initialized'); + } + + /// Clean up resources + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _stateController.close(); + debugPrint('📱 AppLifecycleService disposed'); + } +} + +