diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 978dc29..a7131e5 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -31,6 +31,10 @@ class RouterNotifier extends ChangeNotifier { authNavigationStateProvider, _onStateChanged, ), + ref.listen( + connectivityStatusProvider, + _onStateChanged, + ), ]; } @@ -87,13 +91,18 @@ class RouterNotifier extends ChangeNotifier { ? Routes.chat : Routes.authentication; } - final connectivityAsync = ref.read(connectivityStatusProvider); - final connectivity = connectivityAsync.asData?.value; + // Check connectivity status to determine if we should show connection issue + final connectivity = ref.read(connectivityStatusProvider); + + // Only show connection issue page if: + // 1. Not in reviewer mode + // 2. Connectivity is explicitly offline + // 3. Auth is authenticated (don't interrupt auth flow) final shouldShowConnectionIssue = !reviewerMode && connectivity == ConnectivityStatus.offline && - authState != AuthNavigationState.needsLogin; + authState == AuthNavigationState.authenticated; if (shouldShowConnectionIssue) { return location == Routes.connectionIssue ? null : Routes.connectionIssue; diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart index 9be0e24..c93b66d 100644 --- a/lib/core/services/connectivity_service.dart +++ b/lib/core/services/connectivity_service.dart @@ -1,7 +1,10 @@ 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'; @@ -9,329 +12,284 @@ import '../providers/app_providers.dart'; part 'connectivity_service.g.dart'; -enum ConnectivityStatus { online, offline, checking } +/// 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() { - _startConnectivityMonitoring(); + _initialize(); } final Dio _dio; final Ref _ref; final Connectivity _connectivity; - final _connectivityController = - StreamController.broadcast(); - - Timer? _initialCheckTimer; - Timer? _pollTimer; + final _statusController = StreamController.broadcast(); StreamSubscription>? _connectivitySubscription; - Completer? _activeCheck; - List? _lastConnectivityResults; + Timer? _pollTimer; + Timer? _noNetworkGraceTimer; - ConnectivityStatus _lastStatus = ConnectivityStatus.checking; - Duration _interval = const Duration(seconds: 10); - int _recentFailures = 0; + // 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; - bool _hasNetwork = true; - bool _queuedImmediateCheck = false; - Stream get connectivityStream => - _connectivityController.stream; - ConnectivityStatus get currentStatus => _lastStatus; + Stream get statusStream => _statusController.stream; + ConnectivityStatus get currentStatus => _currentStatus; int get lastLatencyMs => _lastLatencyMs; + bool get isOnline => _currentStatus == ConnectivityStatus.online; - Stream get isConnected => - connectivityStream.map((status) => status == ConnectivityStatus.online); - - bool get isCurrentlyConnected => _lastStatus == ConnectivityStatus.online; - - void _startConnectivityMonitoring() { - _initialCheckTimer = Timer(const Duration(milliseconds: 800), () { - unawaited(_runConnectivityCheck(force: true)); - }); - - _connectivitySubscription = _connectivity.onConnectivityChanged.listen(( - results, - ) { - unawaited(_handleConnectivityChange(results)); - }); - - unawaited( - _connectivity.checkConnectivity().then( - (results) => _handleConnectivityChange(results), - ), + 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(); } - Future _runConnectivityCheck({bool force = false}) async { - if (_connectivityController.isClosed) return; + void _handleNetworkChange(List results) { + final hadNetwork = _hasNetworkInterface; + final hasNetwork = results.any((r) => r != ConnectivityResult.none); + _hasNetworkInterface = hasNetwork; - if (!_hasNetwork) { - _lastLatencyMs = -1; - _updateStatus(ConnectivityStatus.offline); - return; - } - - _initialCheckTimer?.cancel(); - _initialCheckTimer = null; - _cancelScheduledPoll(); - - final existingCheck = _activeCheck; - if (existingCheck != null) { - if (force) { - _queuedImmediateCheck = true; - } - await existingCheck.future; - if (force && _queuedImmediateCheck) { - _queuedImmediateCheck = false; - await _runConnectivityCheck(force: false); + 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; } - final completer = Completer(); - _activeCheck = completer; - - if (_lastStatus != ConnectivityStatus.checking) { - _updateStatus(ConnectivityStatus.checking); + // Network available + _cancelNoNetworkGrace(); + if (!_hasConfirmedNetwork) { + _hasConfirmedNetwork = true; } - try { - await _checkConnectivity(); - } finally { - completer.complete(); - _activeCheck = null; + if (!hadNetwork) { + // Network just came back, check server immediately + _checkServerHealth(); } - - if (_queuedImmediateCheck) { - _queuedImmediateCheck = false; - await _runConnectivityCheck(force: false); - return; - } - - _scheduleNextPoll(); } - void _scheduleNextPoll() { - if (_connectivityController.isClosed || !_hasNetwork) { - return; - } + void _scheduleNextCheck({Duration? delay}) { + _stopPolling(); - _pollTimer = Timer(_interval, () { - _pollTimer = null; - unawaited(_runConnectivityCheck()); + // 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 _cancelScheduledPoll() { + void _stopPolling() { _pollTimer?.cancel(); _pollTimer = null; } - bool _haveSameConnectivity( - List previous, - List current, - ) { - if (identical(previous, current)) return true; - if (previous.length != current.length) return false; - final previousSet = previous.toSet(); - final currentSet = current.toSet(); - if (previousSet.length != currentSet.length) return false; - for (final value in previousSet) { - if (!currentSet.contains(value)) return false; - } - return true; - } - - Future _handleConnectivityChange( - List results, - ) async { - if (_connectivityController.isClosed) return; - - final previousResults = _lastConnectivityResults; - _lastConnectivityResults = results; - final hadNetwork = _hasNetwork; - _hasNetwork = results.any((result) => result != ConnectivityResult.none); - - if (!_hasNetwork) { - _lastLatencyMs = -1; - _queuedImmediateCheck = false; - _cancelScheduledPoll(); - _initialCheckTimer?.cancel(); - _initialCheckTimer = null; - _updateStatus(ConnectivityStatus.offline); + Future _checkServerHealth() async { + if (_statusController.isClosed || !_hasNetworkInterface) { return; } - final networkTypeChanged = previousResults == null - ? true - : !_haveSameConnectivity(previousResults, results); + final isReachable = await _probeServer(); - if (!hadNetwork || - _lastStatus == ConnectivityStatus.offline || - networkTypeChanged) { - unawaited(_runConnectivityCheck(force: true)); - } - } + Duration? overrideDelay; - Future _checkConnectivity() async { - if (_connectivityController.isClosed) return; - - final serverReachability = await _probeActiveServer(); - if (serverReachability != null) { - if (serverReachability) { - _updateStatus(ConnectivityStatus.online); - } else { - _lastLatencyMs = -1; + 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); - } - return; - } - - final fallbackReachability = await _probeAnyKnownServer(); - if (fallbackReachability != null) { - if (fallbackReachability) { - _updateStatus(ConnectivityStatus.online); } else { - _lastLatencyMs = -1; - _updateStatus(ConnectivityStatus.offline); - } - return; - } - - _lastLatencyMs = -1; - _updateStatus(ConnectivityStatus.online); - } - - void _updateStatus(ConnectivityStatus status) { - if (_lastStatus != status) { - _lastStatus = status; - if (!_connectivityController.isClosed) { - _connectivityController.add(status); + overrideDelay = const Duration(seconds: 3); } } - if (status == ConnectivityStatus.offline) { - _recentFailures = (_recentFailures + 1).clamp(0, 10); - } else if (status == ConnectivityStatus.online) { - _recentFailures = 0; + _scheduleNextCheck(delay: overrideDelay); + } + + Future _probeServer() async { + final baseUri = _getServerUri(); + if (baseUri == null) { + // No server configured yet, assume online + return true; } - final newInterval = _recentFailures >= 3 - ? const Duration(seconds: 20) - : _recentFailures == 2 - ? const Duration(seconds: 15) - : const Duration(seconds: 10); - - if (newInterval != _interval) { - _interval = newInterval; - _cancelScheduledPoll(); - if (_lastStatus != ConnectivityStatus.offline && _hasNetwork) { - _scheduleNextPoll(); - } - } - } - - Future checkConnectivity() async { - await _runConnectivityCheck(force: true); - return _lastStatus == ConnectivityStatus.online; - } - - void dispose() { - _initialCheckTimer?.cancel(); - _initialCheckTimer = null; - _cancelScheduledPoll(); - _connectivitySubscription?.cancel(); - _connectivitySubscription = null; - _activeCheck = 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 baseUri, { - bool updateLatency = false, - }) async { try { final start = DateTime.now(); final healthUri = baseUri.resolve('/health'); + final response = await _dio .getUri( healthUri, options: Options( - method: 'GET', - sendTimeout: const Duration(seconds: 3), - receiveTimeout: const Duration(seconds: 3), + sendTimeout: const Duration(seconds: 2), + receiveTimeout: const Duration(seconds: 2), followRedirects: false, validateStatus: (status) => status != null && status < 500, ), ) - .timeout(const Duration(seconds: 4)); + .timeout(const Duration(seconds: 3)); final isHealthy = response.statusCode == 200; - if (isHealthy && updateLatency) { + + if (isHealthy) { _lastLatencyMs = DateTime.now().difference(start).inMilliseconds; + } else { + _lastLatencyMs = -1; } + return isHealthy; } catch (_) { + _lastLatencyMs = -1; return false; } } - Uri? _resolveBaseUri() { + Uri? _getServerUri() { final api = _ref.read(apiServiceProvider); if (api != null) { - return _buildBaseUri(api.baseUrl); + return _parseUri(api.baseUrl); } final activeServer = _ref.read(activeServerProvider); return activeServer.maybeWhen( - data: (server) => server != null ? _buildBaseUri(server.url) : null, + data: (server) => server != null ? _parseUri(server.url) : null, orElse: () => null, ); } - Uri? _buildBaseUri(String baseUrl) { - if (baseUrl.isEmpty) return null; + Uri? _parseUri(String url) { + if (url.isEmpty) return null; - Uri? parsed = Uri.tryParse(baseUrl.trim()); + 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://$baseUrl') ?? Uri.tryParse('http://$baseUrl'); + Uri.tryParse('https://${url.trim()}') ?? + Uri.tryParse('http://${url.trim()}'); } - if (parsed == null) return null; return parsed; } } +// Provider for the connectivity service final connectivityServiceProvider = Provider((ref) { final activeServer = ref.watch(activeServerProvider); @@ -340,7 +298,7 @@ final connectivityServiceProvider = Provider((ref) { if (server == null) { final dio = Dio(); final service = ConnectivityService(dio, ref); - ref.onDispose(() => service.dispose()); + ref.onDispose(service.dispose); return service; } @@ -358,32 +316,36 @@ final connectivityServiceProvider = Provider((ref) { ), ); + if (server.allowSelfSignedCertificates) { + ConnectivityService.configureSelfSignedCerts(dio, server.url); + } + final service = ConnectivityService(dio, ref); - ref.onDispose(() => service.dispose()); + ref.onDispose(service.dispose); return service; }, orElse: () { final dio = Dio(); final service = ConnectivityService(dio, ref); - ref.onDispose(() => service.dispose()); + ref.onDispose(service.dispose); return service; }, ); }); +// Riverpod notifier for connectivity status @Riverpod(keepAlive: true) class ConnectivityStatusNotifier extends _$ConnectivityStatusNotifier { StreamSubscription? _subscription; @override - FutureOr build() { + ConnectivityStatus 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), + _subscription = service.statusStream.listen( + (status) => state = status, + onError: (_, _) {}, // Ignore errors, keep current state ); ref.onDispose(() { @@ -391,17 +353,16 @@ class ConnectivityStatusNotifier extends _$ConnectivityStatusNotifier { _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.when( - data: (status) => status != ConnectivityStatus.offline, - loading: () => true, - error: (error, _) => true, - ); + return status == ConnectivityStatus.online; }); diff --git a/lib/core/services/persistent_streaming_service.dart b/lib/core/services/persistent_streaming_service.dart index 3569428..182c368 100644 --- a/lib/core/services/persistent_streaming_service.dart +++ b/lib/core/services/persistent_streaming_service.dart @@ -74,9 +74,9 @@ class PersistentStreamingService with WidgetsBindingObserver { _connectivitySubscription?.cancel(); _connectivityService = service; - _connectivitySubscription = service.isConnected.listen( - _handleConnectivityChange, - ); + _connectivitySubscription = service.statusStream + .map((status) => status == ConnectivityStatus.online) + .listen(_handleConnectivityChange); } void _handleConnectivityChange(bool connected) { diff --git a/lib/features/auth/views/connection_issue_page.dart b/lib/features/auth/views/connection_issue_page.dart index dd1d9da..a2b164c 100644 --- a/lib/features/auth/views/connection_issue_page.dart +++ b/lib/features/auth/views/connection_issue_page.dart @@ -30,8 +30,7 @@ class _ConnectionIssuePageState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final connectivityAsync = ref.watch(connectivityStatusProvider); - final connectivity = connectivityAsync.asData?.value; + final connectivity = ref.watch(connectivityStatusProvider); final activeServerAsync = ref.watch(activeServerProvider); final activeServer = activeServerAsync.asData?.value; @@ -253,14 +252,12 @@ class _ConnectionIssuePageState extends ConsumerState { } String? _statusLabel(ConnectivityStatus? status, AppLocalizations l10n) { + if (status == null) return null; switch (status) { case ConnectivityStatus.online: return l10n.connectedToServer; case ConnectivityStatus.offline: return l10n.pleaseCheckConnection; - case ConnectivityStatus.checking: - case null: - return null; } } } diff --git a/lib/shared/widgets/offline_indicator.dart b/lib/shared/widgets/offline_indicator.dart index 48b68ba..d959af6 100644 --- a/lib/shared/widgets/offline_indicator.dart +++ b/lib/shared/widgets/offline_indicator.dart @@ -28,20 +28,16 @@ class OfflineIndicator extends ConsumerWidget { orElse: () => false, ); - final overlay = connectivityStatus.when( - data: (status) { - if ((status == ConnectivityStatus.offline || socketOffline) && - !wasOffline) { - return const SizedBox.shrink(); - } - if (wasOffline) { - return const _BackOnlineToast(); - } + final overlay = () { + if ((connectivityStatus == ConnectivityStatus.offline || socketOffline) && + !wasOffline) { return const SizedBox.shrink(); - }, - loading: () => const SizedBox.shrink(), - error: (unusedError, unusedStackTrace) => const SizedBox.shrink(), - ); + } + if (wasOffline) { + return const _BackOnlineToast(); + } + return const SizedBox.shrink(); + }(); return Stack(children: [child, overlay]); } @@ -53,22 +49,16 @@ class _WasOffline extends _$WasOffline { @override bool build() { // Initialize based on current connectivity (assume online until proven otherwise) - ref.listen>(connectivityStatusProvider, ( + ref.listen(connectivityStatusProvider, ( prev, next, ) { - next.when( - data: (status) { - if (status == ConnectivityStatus.offline) { - state = true; // mark that we have been offline - } else if (status == ConnectivityStatus.online && state) { - // After we emit the toast once, clear flag shortly after - Future.microtask(() => state = false); - } - }, - loading: () {}, - error: (error, stackTrace) {}, - ); + if (next == ConnectivityStatus.offline) { + state = true; // mark that we have been offline + } else if (next == ConnectivityStatus.online && state) { + // After we emit the toast once, clear flag shortly after + Future.microtask(() => state = false); + } }); return false; }