import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../providers/app_providers.dart'; part 'connectivity_service.g.dart'; enum ConnectivityStatus { online, offline, checking } class ConnectivityService { final Dio _dio; final Ref _ref; Timer? _connectivityTimer; final _connectivityController = StreamController.broadcast(); ConnectivityStatus _lastStatus = ConnectivityStatus.checking; int _recentFailures = 0; Duration _interval = const Duration(seconds: 10); int _lastLatencyMs = -1; ConnectivityService(this._dio, this._ref) { _startConnectivityMonitoring(); } Stream get connectivityStream => _connectivityController.stream; ConnectivityStatus get currentStatus => _lastStatus; int get lastLatencyMs => _lastLatencyMs; /// Stream that emits true when connected, false when offline Stream get isConnected => connectivityStream.map((status) => status == ConnectivityStatus.online); /// Check if currently connected bool get isCurrentlyConnected => _lastStatus == ConnectivityStatus.online; void _startConnectivityMonitoring() { // Initial check after a brief delay to avoid showing offline during startup Timer(const Duration(milliseconds: 800), () { _checkConnectivity(); }); // Check periodically; interval adapts to recent failures _connectivityTimer = Timer.periodic(_interval, (_) { _checkConnectivity(); }); } Future _checkConnectivity() async { // Don't check connectivity if service is disposed if (_connectivityController.isClosed) return; final serverReachability = await _probeActiveServer(); if (serverReachability != null) { if (serverReachability) { _updateStatus(ConnectivityStatus.online); } else { _lastLatencyMs = -1; _updateStatus(ConnectivityStatus.offline); } return; } final fallbackReachability = await _probeAnyKnownServer(); if (fallbackReachability != null) { if (fallbackReachability) { _updateStatus(ConnectivityStatus.online); } else { _lastLatencyMs = -1; _updateStatus(ConnectivityStatus.offline); } return; } // No configured server to probe; assume usable connectivity so setup flows continue. _lastLatencyMs = -1; _updateStatus(ConnectivityStatus.online); } void _updateStatus(ConnectivityStatus status) { if (_lastStatus != status) { _lastStatus = status; // Only add to stream if controller is not closed if (!_connectivityController.isClosed) { _connectivityController.add(status); } } // Adapt polling interval based on recent failures to reduce battery/CPU if (status == ConnectivityStatus.offline) { _recentFailures = (_recentFailures + 1).clamp(0, 10); } else if (status == ConnectivityStatus.online) { _recentFailures = 0; } final newInterval = _recentFailures >= 3 ? const Duration(seconds: 20) : _recentFailures == 2 ? const Duration(seconds: 15) : const Duration(seconds: 10); if (newInterval != _interval) { _interval = newInterval; _connectivityTimer?.cancel(); // Only create new timer if service is not disposed if (!_connectivityController.isClosed) { _connectivityTimer = Timer.periodic( _interval, (_) => _checkConnectivity(), ); } } } Future checkConnectivity() async { await _checkConnectivity(); return _lastStatus == ConnectivityStatus.online; } void dispose() { _connectivityTimer?.cancel(); _connectivityTimer = null; if (!_connectivityController.isClosed) { _connectivityController.close(); } } Future _probeActiveServer() async { final baseUri = _resolveBaseUri(); if (baseUri == null) return null; return _probeBaseEndpoint(baseUri, updateLatency: true); } Future _probeAnyKnownServer() async { try { final configs = await _ref.read(serverConfigsProvider.future); for (final config in configs) { final uri = _buildBaseUri(config.url); if (uri == null) continue; final result = await _probeBaseEndpoint(uri); if (result != null) { return result; } } } catch (_) {} return null; } Future _probeBaseEndpoint( Uri uri, { bool updateLatency = false, }) async { try { final start = DateTime.now(); final response = await _dio .getUri( uri, options: Options( method: 'GET', sendTimeout: const Duration(seconds: 3), receiveTimeout: const Duration(seconds: 3), followRedirects: false, validateStatus: (status) => status != null && status < 500, ), ) .timeout(const Duration(seconds: 4)); final isHealthy = response.statusCode == 200; if (isHealthy && updateLatency) { _lastLatencyMs = DateTime.now().difference(start).inMilliseconds; } return isHealthy; } catch (_) { // Treat as unreachable. return false; } } Uri? _resolveBaseUri() { final api = _ref.read(apiServiceProvider); if (api != null) { return _buildBaseUri(api.baseUrl); } final activeServer = _ref.read(activeServerProvider); return activeServer.maybeWhen( data: (server) => server != null ? _buildBaseUri(server.url) : null, orElse: () => null, ); } Uri? _buildBaseUri(String baseUrl) { if (baseUrl.isEmpty) return null; Uri? parsed = Uri.tryParse(baseUrl.trim()); if (parsed == null) return null; if (!parsed.hasScheme) { parsed = Uri.tryParse('https://$baseUrl') ?? Uri.tryParse('http://$baseUrl'); } if (parsed == null) return null; // Return the base URL directly instead of resolving to /health return parsed; } } // Providers final connectivityServiceProvider = Provider((ref) { // Create a Dio instance with custom headers from the active server config final activeServer = ref.watch(activeServerProvider); return activeServer.maybeWhen( data: (server) { if (server == null) { // No server configured, use lightweight Dio final dio = Dio(); final service = ConnectivityService(dio, ref); ref.onDispose(() => service.dispose()); return service; } // Create Dio with custom headers from server config final dio = Dio( BaseOptions( baseUrl: server.url, connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(seconds: 30), followRedirects: true, maxRedirects: 5, validateStatus: (status) => status != null && status < 400, // Add custom headers from server config headers: server.customHeaders.isNotEmpty ? Map.from(server.customHeaders) : null, ), ); final service = ConnectivityService(dio, ref); ref.onDispose(() => service.dispose()); return service; }, orElse: () { // No server data available, use lightweight Dio final dio = Dio(); final service = ConnectivityService(dio, ref); ref.onDispose(() => service.dispose()); return service; }, ); }); @Riverpod(keepAlive: true) class ConnectivityStatusNotifier extends _$ConnectivityStatusNotifier { StreamSubscription? _subscription; @override FutureOr build() { final service = ref.watch(connectivityServiceProvider); _subscription?.cancel(); _subscription = service.connectivityStream.listen( (status) => state = AsyncValue.data(status), onError: (error, stackTrace) => state = AsyncValue.error(error, stackTrace), ); ref.onDispose(() { _subscription?.cancel(); _subscription = null; }); return service.currentStatus; } } final isOnlineProvider = Provider((ref) { // In reviewer mode, treat app as online to enable flows final reviewerMode = ref.watch(reviewerModeProvider); if (reviewerMode) return true; final status = ref.watch(connectivityStatusProvider); return status.when( data: (status) => status != ConnectivityStatus.offline, loading: () => true, // Assume online while checking error: (_, _) => true, // Assume online on error to avoid false offline states ); }); // Dio provider (if not already defined elsewhere) // Removed unused Dio provider to avoid confusion. Use ApiService instead.