diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 62910ca..7c30c1e 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -204,6 +204,9 @@ final socketServiceProvider = Provider((ref) { // best-effort connect; errors handled internally // ignore unawaited_futures s.connect(); + ref.onDispose(() { + try { s.dispose(); } catch (_) {} + }); return s; }, orElse: () => null, diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart new file mode 100644 index 0000000..96f2c36 --- /dev/null +++ b/lib/core/providers/app_startup_providers.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/app_providers.dart'; +import '../../features/auth/providers/unified_auth_providers.dart'; +import '../services/navigation_service.dart'; +import '../../features/onboarding/views/onboarding_sheet.dart'; +import '../../shared/theme/theme_extensions.dart'; +import '../utils/debug_logger.dart'; + +/// App-level startup/background task flow orchestrator. +/// +/// Moves background initialization out of widgets and into a Riverpod provider, +/// keeping UI lean and business logic centralized. +final appStartupFlowProvider = Provider((ref) { + // Ensure token integration listeners are active + ref.watch(authApiIntegrationProvider); + ref.watch(apiTokenUpdaterProvider); + + // Kick background model loading flow (non-blocking) + ref.watch(backgroundModelLoadProvider); + + // If authenticated, keep socket service alive and connected + final navState = ref.watch(authNavigationStateProvider); + if (navState == AuthNavigationState.authenticated) { + ref.watch(socketServiceProvider); + } + + // When auth state becomes authenticated, run additional background work + ref.listen(authNavigationStateProvider, + (prev, next) { + if (next == AuthNavigationState.authenticated) { + // Schedule microtask so we don't perform side-effects inside build + Future.microtask(() async { + try { + final api = ref.read(apiServiceProvider); + if (api == null) { + DebugLogger.warning( + 'API service not available for startup flow', + ); + return; + } + + // Ensure API has the latest token immediately + final authToken = ref.read(authTokenProvider3); + if (authToken != null && authToken.isNotEmpty) { + api.updateAuthToken(authToken); + DebugLogger.auth('StartupFlow: Applied auth token to API'); + } + + // Preload default model in background (best-effort) + try { + await ref.read(defaultModelProvider.future); + } catch (e) { + DebugLogger.warning('StartupFlow: default model preload failed: $e'); + } + + // Show onboarding once when user reaches chat and hasn't seen it yet + await _maybeShowOnboarding(ref); + } catch (e) { + DebugLogger.error('StartupFlow error', e); + } + }); + } + }); +}); + +Future _maybeShowOnboarding(Ref ref) async { + try { + final storage = ref.read(optimizedStorageServiceProvider); + final seen = await storage.getOnboardingSeen(); + if (seen) return; + + // Small delay to allow initial navigation/frame to settle + await Future.delayed(const Duration(milliseconds: 300)); + + // Only surface onboarding on the chat route to avoid interrupting flows + if (NavigationService.currentRoute != Routes.chat) return; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final navContext = NavigationService.navigatorKey.currentContext; + if (navContext == null) return; + + // Show onboarding sheet + showModalBottomSheet( + context: navContext, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + boxShadow: ConduitShadows.modal, + ), + child: const OnboardingSheet(), + ), + ); + + await storage.setOnboardingSeen(true); + }); + } catch (e) { + // Best-effort only; never fail app startup due to onboarding + DebugLogger.warning('StartupFlow: onboarding display failed: $e'); + } +} diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 5135c38..7a6273a 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -1022,10 +1022,13 @@ Future _sendMessageInternal( return; } - // Stream response using SSE + // Stream response using server-push via Socket when available, otherwise fallback // Resolve Socket session for background tasks parity final socketService = ref.read(socketServiceProvider); final socketSessionId = socketService?.sessionId; + final bool wantSessionBinding = + (socketService?.isConnected == true) && + (socketSessionId != null && socketSessionId.isNotEmpty); // Resolve tool servers from user settings (if any) List>? toolServers; @@ -1060,9 +1063,9 @@ Future _sendMessageInternal( // handled via pre-stream client-side request above enableImageGeneration: false, modelItem: modelItem, - // Only pass a session when we truly want task-based dynamic-channel - // behavior; for pure text flows prefer polling (if background mode). - sessionIdOverride: isBackgroundToolsFlowPre ? socketSessionId : null, + // Bind to Socket session whenever available so the server can push + // streaming updates to this client (improves first-turn streaming). + sessionIdOverride: wantSessionBinding ? socketSessionId : null, toolServers: toolServers, backgroundTasks: bgTasks, ); @@ -1083,11 +1086,11 @@ Future _sendMessageInternal( ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage); // If socket is available, start listening for chat-events immediately - // Background-tools flow (tools/tool servers) relies on socket/dynamic channel for - // streaming content. Allow socket TEXT in that mode. For pure SSE flows, suppress + // Background-tools flow OR any session-bound flow relies on socket/dynamic channel for + // streaming content. Allow socket TEXT in those modes. For pure SSE/polling flows, suppress // socket TEXT to avoid duplicates (still surface tool_call status). - final bool isBackgroundToolsFlow = isBackgroundToolsFlowPre; - bool suppressSocketContent = !isBackgroundToolsFlow; // allow socket text for tools + final bool isBackgroundFlow = isBackgroundToolsFlowPre || wantSessionBinding; + bool suppressSocketContent = !isBackgroundFlow; // allow socket text when session-bound or tools bool usingDynamicChannel = false; // set true when server provides a channel if (socketService != null) { void chatHandler(Map ev) { @@ -1737,7 +1740,7 @@ Future _sendMessageInternal( suppressSocketContent = false; // If this path was SSE-driven (no background tools/dynamic channel), finish now. // Otherwise keep streaming state until socket/dynamic channel signals done. - if (!usingDynamicChannel && !isBackgroundToolsFlow) { + if (!usingDynamicChannel && !isBackgroundFlow) { ref.read(chatMessagesProvider.notifier).finishStreaming(); } @@ -1781,7 +1784,7 @@ Future _sendMessageInternal( // Only notify completion immediately for non-background SSE flows. // For background tools/dynamic-channel flows, defer completion // until the socket/dynamic channel signals done. - if (!isBackgroundToolsFlow && !usingDynamicChannel) { + if (!isBackgroundFlow && !usingDynamicChannel) { try { unawaited( api diff --git a/lib/main.dart b/lib/main.dart index 4ba76dd..c184ea5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,11 +15,11 @@ import 'features/auth/providers/unified_auth_providers.dart'; import 'core/auth/auth_state_manager.dart'; import 'core/utils/debug_logger.dart'; -import 'features/onboarding/views/onboarding_sheet.dart'; import 'package:conduit/l10n/app_localizations.dart'; import 'features/chat/views/chat_page.dart'; import 'features/navigation/views/splash_launcher_page.dart'; import 'core/services/share_receiver_service.dart'; +import 'core/providers/app_startup_providers.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -203,7 +203,8 @@ class _ConduitAppState extends ConsumerState { } // User is authenticated, navigate directly to chat page - _initializeBackgroundResources(ref); + // Kick off and keep app startup/background task flow alive + ref.watch(appStartupFlowProvider); // Set the current route for navigation tracking NavigationService.setCurrentRoute(Routes.chat); @@ -222,76 +223,7 @@ class _ConduitAppState extends ConsumerState { ); } - void _initializeBackgroundResources(WidgetRef ref) { - // Initialize resources in the background without blocking UI - Future.microtask(() async { - try { - // Get the API service - final api = ref.read(apiServiceProvider); - if (api == null) { - DebugLogger.warning( - 'API service not available for background initialization', - ); - return; - } - - // Explicitly get the current auth token and set it on the API service - final authToken = ref.read(authTokenProvider3); - if (authToken != null && authToken.isNotEmpty) { - api.updateAuthToken(authToken); - DebugLogger.auth('Background: Set auth token on API service'); - } else { - DebugLogger.warning('Background: No auth token available yet'); - return; - } - - // Initialize the token updater for future updates - ref.read(apiTokenUpdaterProvider); - - // Load models and set default in background - await ref.read(defaultModelProvider.future); - DebugLogger.info('Background initialization completed'); - - // Onboarding: show once if not seen - final storage = ref.read(optimizedStorageServiceProvider); - final seen = await storage.getOnboardingSeen(); - - if (!seen && mounted) { - await Future.delayed(const Duration(milliseconds: 300)); - if (!mounted) return; - - WidgetsBinding.instance.addPostFrameCallback((_) async { - final navContext = NavigationService.navigatorKey.currentContext; - if (!mounted || navContext == null) return; - - _showOnboarding(navContext); - await storage.setOnboardingSeen(true); - }); - } - } catch (e) { - DebugLogger.error('Background initialization failed', e); - // Don't throw - this is background initialization - } - }); - } - - void _showOnboarding(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => Container( - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppBorderRadius.modal), - ), - boxShadow: ConduitShadows.modal, - ), - child: const OnboardingSheet(), - ), - ); - } + // Background initialization moved to app-based task flow provider Widget _buildErrorState(String error) { return Scaffold(