Merge branch 'master' into faiz_dev
commit
f3f47dfa4f
@ -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<http.Response> post({
|
||||||
|
required Uri uri,
|
||||||
|
Map<String, String>? 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<http.Response> get({
|
||||||
|
required Uri uri,
|
||||||
|
Map<String, String>? 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<http.Response> _executeWithRetry(
|
||||||
|
Future<http.Response> 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<void> _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<void>();
|
||||||
|
late StreamSubscription<AppLifecycleState> 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<void> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -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<AppLifecycleState>.broadcast();
|
||||||
|
|
||||||
|
/// Stream of app lifecycle state changes
|
||||||
|
Stream<AppLifecycleState> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue