import 'dart:async'; import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/foundation.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'; /// Connectivity status for the app. /// - [online]: Server is reachable /// - [offline]: No network or server unreachable enum ConnectivityStatus { online, offline } /// Simplified connectivity service that monitors network and server health. /// /// Key improvements: /// - No "checking" state to prevent UI flashing /// - Assumes online by default (optimistic) /// - Only shows offline when explicitly confirmed /// - Minimal state changes during startup class ConnectivityService { ConnectivityService(this._dio, this._ref, [Connectivity? connectivity]) : _connectivity = connectivity ?? Connectivity() { _initialize(); } final Dio _dio; final Ref _ref; final Connectivity _connectivity; final _statusController = StreamController.broadcast(); StreamSubscription>? _connectivitySubscription; Timer? _pollTimer; Timer? _noNetworkGraceTimer; // Start optimistically as online to prevent flash ConnectivityStatus _currentStatus = ConnectivityStatus.online; bool _hasNetworkInterface = false; bool _hasConfirmedNetwork = false; bool _hasSuccessfulProbe = false; int _consecutiveFailures = 0; int _lastLatencyMs = -1; Stream get statusStream => _statusController.stream; ConnectivityStatus get currentStatus => _currentStatus; int get lastLatencyMs => _lastLatencyMs; bool get isOnline => _currentStatus == ConnectivityStatus.online; void _initialize() { // Listen to network interface changes _connectivitySubscription = _connectivity.onConnectivityChanged.listen( _handleNetworkChange, onError: (_) {}, // Ignore connectivity errors ); // Check initial network state immediately _connectivity.checkConnectivity().then(_handleNetworkChange); // Start periodic health checks _scheduleNextCheck(); } void _handleNetworkChange(List results) { final hadNetwork = _hasNetworkInterface; final hasNetwork = results.any((r) => r != ConnectivityResult.none); _hasNetworkInterface = hasNetwork; if (!hasNetwork) { if (hadNetwork || _hasConfirmedNetwork) { // Lost network after previously confirming it _cancelNoNetworkGrace(); _updateStatus(ConnectivityStatus.offline); _stopPolling(); } else { // During startup we often get a transient "none" result. // Defer emitting offline until it persists beyond the grace window. _noNetworkGraceTimer ??= Timer(const Duration(seconds: 2), () { if (!_hasNetworkInterface) { _updateStatus(ConnectivityStatus.offline); _stopPolling(); } }); } return; } // Network available _cancelNoNetworkGrace(); if (!_hasConfirmedNetwork) { _hasConfirmedNetwork = true; } if (!hadNetwork) { // Network just came back, check server immediately _checkServerHealth(); } } void _scheduleNextCheck({Duration? delay}) { _stopPolling(); // Adaptive polling based on failure count final interval = delay ?? (_consecutiveFailures >= 3 ? const Duration(seconds: 30) : _consecutiveFailures >= 1 ? const Duration(seconds: 20) : const Duration(seconds: 15)); _pollTimer = Timer(interval, () { if (_hasNetworkInterface) { _checkServerHealth(); } }); } void _stopPolling() { _pollTimer?.cancel(); _pollTimer = null; } Future _checkServerHealth() async { if (_statusController.isClosed || !_hasNetworkInterface) { return; } final isReachable = await _probeServer(); Duration? overrideDelay; if (isReachable) { _consecutiveFailures = 0; _hasSuccessfulProbe = true; _updateStatus(ConnectivityStatus.online); } else { _consecutiveFailures++; // Only surface offline after we've confirmed the server once or we have // multiple consecutive failures. This avoids startup flicker where // authorization or DNS is still settling. if (_hasSuccessfulProbe || _consecutiveFailures >= 2) { _updateStatus(ConnectivityStatus.offline); } else { overrideDelay = const Duration(seconds: 3); } } _scheduleNextCheck(delay: overrideDelay); } Future _probeServer() async { final baseUri = _getServerUri(); if (baseUri == null) { // No server configured yet, assume online return true; } try { final start = DateTime.now(); final healthUri = baseUri.resolve('/health'); final response = await _dio .getUri( healthUri, options: Options( sendTimeout: const Duration(seconds: 2), receiveTimeout: const Duration(seconds: 2), followRedirects: false, validateStatus: (status) => status != null && status < 500, ), ) .timeout(const Duration(seconds: 3)); final isHealthy = response.statusCode == 200; if (isHealthy) { _lastLatencyMs = DateTime.now().difference(start).inMilliseconds; } else { _lastLatencyMs = -1; } return isHealthy; } catch (_) { _lastLatencyMs = -1; return false; } } Uri? _getServerUri() { final api = _ref.read(apiServiceProvider); if (api != null) { return _parseUri(api.baseUrl); } final activeServer = _ref.read(activeServerProvider); return activeServer.maybeWhen( data: (server) => server != null ? _parseUri(server.url) : null, orElse: () => null, ); } Uri? _parseUri(String url) { if (url.isEmpty) return null; Uri? parsed = Uri.tryParse(url.trim()); if (parsed == null) return null; if (!parsed.hasScheme) { parsed = Uri.tryParse('https://$url') ?? Uri.tryParse('http://$url'); } return parsed; } void _updateStatus(ConnectivityStatus newStatus) { if (_currentStatus != newStatus && !_statusController.isClosed) { _currentStatus = newStatus; _statusController.add(newStatus); } } void _cancelNoNetworkGrace() { _noNetworkGraceTimer?.cancel(); _noNetworkGraceTimer = null; } /// Manually trigger a connectivity check. Future checkNow() async { await _checkServerHealth(); return _currentStatus == ConnectivityStatus.online; } void dispose() { _stopPolling(); _connectivitySubscription?.cancel(); _connectivitySubscription = null; _cancelNoNetworkGrace(); if (!_statusController.isClosed) { _statusController.close(); } } /// Configures the Dio instance to accept self-signed certificates. static void configureSelfSignedCerts(Dio dio, String serverUrl) { if (kIsWeb) return; final uri = _parseStaticUri(serverUrl); if (uri == null) return; final adapter = dio.httpClientAdapter; if (adapter is! IOHttpClientAdapter) return; adapter.createHttpClient = () { final client = HttpClient(); final host = uri.host.toLowerCase(); final port = uri.hasPort ? uri.port : null; client.badCertificateCallback = (X509Certificate cert, String requestHost, int requestPort) { if (requestHost.toLowerCase() != host) return false; if (port == null) return true; return requestPort == port; }; return client; }; } static Uri? _parseStaticUri(String url) { if (url.trim().isEmpty) return null; Uri? parsed = Uri.tryParse(url.trim()); if (parsed == null) return null; if (!parsed.hasScheme) { parsed = Uri.tryParse('https://${url.trim()}') ?? Uri.tryParse('http://${url.trim()}'); } return parsed; } } // Provider for the connectivity service final connectivityServiceProvider = Provider((ref) { final activeServer = ref.watch(activeServerProvider); return activeServer.maybeWhen( data: (server) { if (server == null) { final dio = Dio(); final service = ConnectivityService(dio, ref); ref.onDispose(service.dispose); return service; } 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, headers: server.customHeaders.isNotEmpty ? Map.from(server.customHeaders) : null, ), ); if (server.allowSelfSignedCertificates) { ConnectivityService.configureSelfSignedCerts(dio, server.url); } final service = ConnectivityService(dio, ref); ref.onDispose(service.dispose); return service; }, orElse: () { final dio = Dio(); final service = ConnectivityService(dio, ref); ref.onDispose(service.dispose); return service; }, ); }); // Riverpod notifier for connectivity status @Riverpod(keepAlive: true) class ConnectivityStatusNotifier extends _$ConnectivityStatusNotifier { StreamSubscription? _subscription; @override ConnectivityStatus build() { final service = ref.watch(connectivityServiceProvider); _subscription?.cancel(); _subscription = service.statusStream.listen( (status) => state = status, onError: (_, _) {}, // Ignore errors, keep current state ); ref.onDispose(() { _subscription?.cancel(); _subscription = null; }); // Return current status immediately (starts as online) return service.currentStatus; } } // Simple provider for checking if online final isOnlineProvider = Provider((ref) { final reviewerMode = ref.watch(reviewerModeProvider); if (reviewerMode) return true; final status = ref.watch(connectivityStatusProvider); return status == ConnectivityStatus.online; });