From 762155500b13e19d3676ca98981b1c8cce94ac09 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Tue, 14 Oct 2025 22:32:09 +0530 Subject: [PATCH] feat: Improve offline detection logic This commit refactors the connectivity service to be more robust and less prone to UI flicker. Key changes: - Any successful API response now briefly suppresses the offline warning. This prevents the UI from flashing an offline message between regular connectivity checks. - The threshold for showing the offline warning is increased from 2 to 3 consecutive failed health checks. - The timeout for health checks is increased to better handle slow networks. - The offline warning is now suppressed if there are active data streams to avoid interrupting the user. - A custom JSON converter is added for conversation metadata to handle potential type mismatches from local storage. --- lib/core/models/conversation.dart | 21 ++++++++++++- lib/core/router/app_router.dart | 7 ++++- lib/core/services/api_service.dart | 22 ++++++++++++- lib/core/services/connectivity_service.dart | 34 +++++++++++++++------ 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/lib/core/models/conversation.dart b/lib/core/models/conversation.dart index 62bb1e8..9a612ce 100644 --- a/lib/core/models/conversation.dart +++ b/lib/core/models/conversation.dart @@ -14,7 +14,7 @@ sealed class Conversation with _$Conversation { String? model, String? systemPrompt, @Default([]) List messages, - @Default({}) Map metadata, + @Default({}) @_MetadataConverter() Map metadata, @Default(false) bool pinned, @Default(false) bool archived, String? shareId, @@ -25,3 +25,22 @@ sealed class Conversation with _$Conversation { factory Conversation.fromJson(Map json) => _$ConversationFromJson(json); } + +/// Custom converter to handle Map from storage +class _MetadataConverter + implements JsonConverter, Object?> { + const _MetadataConverter(); + + @override + Map fromJson(Object? json) { + if (json == null) return {}; + if (json is Map) return json; + if (json is Map) { + return json.map((key, value) => MapEntry(key.toString(), value)); + } + return {}; + } + + @override + Object? toJson(Map object) => object; +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index bff9e2c..7b4acf5 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../providers/app_providers.dart'; import '../services/connectivity_service.dart'; import '../services/navigation_service.dart'; +import '../services/persistent_streaming_service.dart'; import '../utils/debug_logger.dart'; import '../../features/auth/providers/unified_auth_providers.dart'; import '../../features/auth/views/authentication_page.dart'; @@ -103,12 +104,16 @@ class RouterNotifier extends ChangeNotifier { // 1. Not in reviewer mode // 2. Connectivity is explicitly offline // 3. Auth is authenticated (don't interrupt auth flow) + // 4. App is in foreground and offline warning isn't suppressed + // 5. No active streaming is in progress (avoid interrupting token streams) + final hasActiveStreams = PersistentStreamingService().activeStreamCount > 0; final shouldShowConnectionIssue = !reviewerMode && connectivity == ConnectivityStatus.offline && authState == AuthNavigationState.authenticated && connectivityService.isAppForeground && - !connectivityService.isOfflineSuppressed; + !connectivityService.isOfflineSuppressed && + !hasActiveStreams; if (shouldShowConnectionIssue) { return location == Routes.connectionIssue ? null : Routes.connectionIssue; diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 3f0b622..f1b49df 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -16,6 +16,7 @@ import '../auth/api_auth_interceptor.dart'; import '../error/api_error_interceptor.dart'; // Tool-call details are parsed in the UI layer to render collapsible blocks import 'persistent_streaming_service.dart'; +import 'connectivity_service.dart'; import '../utils/debug_logger.dart'; import '../utils/openwebui_source_parser.dart'; @@ -90,7 +91,26 @@ class ApiService { ), ); - // 4. Custom debug interceptor to log exactly what we're sending + // 4. Success pings to relax offline detection. + // Any successful API response indicates recent connectivity; suppress + // offline transitions briefly to avoid UI flicker. + _dio.interceptors.add( + InterceptorsWrapper( + onResponse: (response, handler) { + try { + if ((response.statusCode ?? 0) >= 200 && + (response.statusCode ?? 0) < 400) { + ConnectivityService.suppressOfflineGlobally( + const Duration(seconds: 4), + ); + } + } catch (_) {} + handler.next(response); + }, + ), + ); + + // 5. Custom debug interceptor to log exactly what we're sending if (kDebugMode) { _dio.interceptors.add( InterceptorsWrapper( diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart index 8a0a9eb..a6a2e55 100644 --- a/lib/core/services/connectivity_service.dart +++ b/lib/core/services/connectivity_service.dart @@ -46,7 +46,6 @@ class ConnectivityService with WidgetsBindingObserver { ConnectivityStatus _currentStatus = ConnectivityStatus.online; bool _hasNetworkInterface = false; bool _hasConfirmedNetwork = false; - bool _hasSuccessfulProbe = false; int _consecutiveFailures = 0; int _lastLatencyMs = -1; @@ -149,16 +148,15 @@ class ConnectivityService with WidgetsBindingObserver { 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) { + // Require more consecutive failures to reduce false negatives. + // Switch to offline only after >= 3 consecutive failures. + if (_consecutiveFailures >= 3) { _updateStatus(ConnectivityStatus.offline); } else { + // Shorter retry when still below threshold. overrideDelay = const Duration(seconds: 3); } } @@ -181,13 +179,13 @@ class ConnectivityService with WidgetsBindingObserver { .getUri( healthUri, options: Options( - sendTimeout: const Duration(seconds: 2), - receiveTimeout: const Duration(seconds: 2), + sendTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), followRedirects: false, validateStatus: (status) => status != null && status < 500, ), ) - .timeout(const Duration(seconds: 3)); + .timeout(const Duration(seconds: 6)); final isHealthy = response.statusCode == 200; @@ -250,6 +248,11 @@ class ConnectivityService with WidgetsBindingObserver { bool get _isOfflineSuppressed { final until = _offlineSuppressedUntil; + // Check process-wide suppression window (set by API layer on successes) + final globalUntil = _globalOfflineSuppressedUntil; + if (globalUntil != null && DateTime.now().isBefore(globalUntil)) { + return true; + } if (until == null) { return false; } @@ -269,6 +272,19 @@ class ConnectivityService with WidgetsBindingObserver { } } + // ===== Global suppression signaling (from API layer) ===== + static DateTime? _globalOfflineSuppressedUntil; + + /// Suppress offline transitions globally for a short window. Useful + /// to avoid flicker after known-good API responses. + static void suppressOfflineGlobally(Duration duration) { + final proposed = DateTime.now().add(duration); + if (_globalOfflineSuppressedUntil == null || + proposed.isAfter(_globalOfflineSuppressedUntil!)) { + _globalOfflineSuppressedUntil = proposed; + } + } + /// Manually trigger a connectivity check. Future checkNow() async { await _checkServerHealth();