diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 660e151..b65f6db 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -541,12 +541,10 @@ final loadConversationProvider = FutureProvider.family(( // Provider to automatically load and set the default model from user settings or OpenWebUI final defaultModelProvider = FutureProvider((ref) async { - // Initialize the settings watcher - ref.watch(_settingsWatcherProvider); - // Watch user settings to refresh when default model changes - ref.watch(appSettingsProvider); - // Handle reviewer mode first - final reviewerMode = ref.watch(reviewerModeProvider); + // Initialize the settings watcher (side-effect only) + ref.read(_settingsWatcherProvider); + // Read settings without subscribing to rebuilds to avoid watch/await hazards + final reviewerMode = ref.read(reviewerModeProvider); if (reviewerMode) { // Check if a model is manually selected final currentSelected = ref.read(selectedModelProvider); @@ -563,20 +561,18 @@ final defaultModelProvider = FutureProvider((ref) async { final models = await ref.read(modelsProvider.future); if (models.isNotEmpty) { final defaultModel = models.first; - Future.microtask(() { - if (!ref.read(isManualModelSelectionProvider)) { - ref.read(selectedModelProvider.notifier).state = defaultModel; - foundation.debugPrint( - 'DEBUG: Auto-selected demo model: ${defaultModel.name}', - ); - } - }); + if (!ref.read(isManualModelSelectionProvider)) { + ref.read(selectedModelProvider.notifier).state = defaultModel; + foundation.debugPrint( + 'DEBUG: Auto-selected demo model: ${defaultModel.name}', + ); + } return defaultModel; } return null; } - final api = ref.watch(apiServiceProvider); + final api = ref.read(apiServiceProvider); if (api == null) return null; try { @@ -656,15 +652,11 @@ final defaultModelProvider = FutureProvider((ref) async { } } - // Defer the state update to avoid modifying providers during initialization - final modelToSet = selectedModel; - Future.microtask(() { - // Only update if this is not a manual selection - if (!ref.read(isManualModelSelectionProvider)) { - ref.read(selectedModelProvider.notifier).state = modelToSet; - foundation.debugPrint('DEBUG: Set default model: ${modelToSet.name}'); - } - }); + // Update selection immediately inside provider context + if (!ref.read(isManualModelSelectionProvider)) { + ref.read(selectedModelProvider.notifier).state = selectedModel; + foundation.debugPrint('DEBUG: Set default model: ${selectedModel.name}'); + } return selectedModel; } catch (e) { @@ -675,15 +667,12 @@ final defaultModelProvider = FutureProvider((ref) async { final models = await ref.read(modelsProvider.future); if (models.isNotEmpty) { final fallbackModel = models.first; - // Defer the state update - Future.microtask(() { - if (!ref.read(isManualModelSelectionProvider)) { - ref.read(selectedModelProvider.notifier).state = fallbackModel; - foundation.debugPrint( - 'DEBUG: Fallback to first available model: ${fallbackModel.name}', - ); - } - }); + if (!ref.read(isManualModelSelectionProvider)) { + ref.read(selectedModelProvider.notifier).state = fallbackModel; + foundation.debugPrint( + 'DEBUG: Fallback to first available model: ${fallbackModel.name}', + ); + } return fallbackModel; } } catch (fallbackError) { diff --git a/lib/core/services/share_receiver_service.dart b/lib/core/services/share_receiver_service.dart index 1625430..93838ba 100644 --- a/lib/core/services/share_receiver_service.dart +++ b/lib/core/services/share_receiver_service.dart @@ -9,7 +9,8 @@ import '../../features/auth/providers/unified_auth_providers.dart'; import '../../features/chat/providers/chat_providers.dart'; import '../../features/chat/services/file_attachment_service.dart'; import '../../core/providers/app_providers.dart'; -// No server chat creation here; follow chat flow on first send +import 'navigation_service.dart'; +// Server chat creation/title generation occur on first send via chat providers /// Lightweight payload for a share event class SharedPayload { @@ -26,20 +27,21 @@ final pendingSharedPayloadProvider = StateProvider((_) => null); /// Initializes listening to OS share intents and handles them final shareReceiverInitializerProvider = Provider((ref) { - // Do nothing on web/desktop + // Only mobile platforms handle OS share intents if (kIsWeb) return; - - final sub = StreamController.broadcast(); + if (!(Platform.isAndroid || Platform.isIOS)) return; // Listen for app readiness: authenticated and model available void maybeProcessPending() { final navState = ref.read(authNavigationStateProvider); final model = ref.read(selectedModelProvider); final pending = ref.read(pendingSharedPayloadProvider); + final isOnChatRoute = NavigationService.currentRoute == Routes.chat; if (pending != null && pending.hasAnything && navState == AuthNavigationState.authenticated && - model != null) { + model != null && + isOnChatRoute) { _processPayload(ref, pending); ref.read(pendingSharedPayloadProvider.notifier).state = null; } @@ -51,6 +53,8 @@ final shareReceiverInitializerProvider = Provider((ref) { (_, __) => maybeProcessPending(), ); ref.listen(selectedModelProvider, (_, __) => maybeProcessPending()); + // Also poll once shortly after navigation settles to ensure ChatPage is ready + Future.delayed(const Duration(milliseconds: 150), () => maybeProcessPending()); // Hook into share_handler final handler = sh.ShareHandler.instance; @@ -85,7 +89,6 @@ final shareReceiverInitializerProvider = Provider((ref) { // Ensure cleanup ref.onDispose(() async { await streamSub.cancel(); - await sub.close(); }); }); @@ -169,9 +172,8 @@ Future _processPayload(Ref ref, SharedPayload payload) async { final current = ref.read(inputFocusTriggerProvider); ref.read(inputFocusTriggerProvider.notifier).state = current + 1; } - // Do NOT create a placeholder server chat here. The drawer will refresh - // when the user sends their first message, matching in-app behavior. - // This allows the user to add a caption before sending + // Do NOT create a server chat here. The chat is created on first send + // (with server syncing + title generation) in chat_providers.dart. } catch (e) { debugPrint('ShareReceiver: failed to process payload: $e'); } diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index cc86621..12ef90e 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -50,6 +50,7 @@ class _ChatPageState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedMessageIds = {}; Timer? _scrollDebounceTimer; + bool _isDeactivated = false; String _formatModelDisplayName(String name) { var display = name.trim(); @@ -253,6 +254,19 @@ class _ChatPageState extends ConsumerState { super.dispose(); } + @override + void deactivate() { + _isDeactivated = true; + _scrollDebounceTimer?.cancel(); + super.deactivate(); + } + + @override + void activate() { + super.activate(); + _isDeactivated = false; + } + void _handleMessageSend(String text, dynamic selectedModel) async { if (selectedModel == null) { return; @@ -460,7 +474,7 @@ class _ChatPageState extends ConsumerState { if (_scrollDebounceTimer?.isActive == true) return; _scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () { - if (!mounted || !_scrollController.hasClients) return; + if (!mounted || _isDeactivated || !_scrollController.hasClients) return; final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.position.pixels; @@ -483,7 +497,7 @@ class _ChatPageState extends ConsumerState { showButton = farFromBottom && maxScroll > showThreshold; } - if (showButton != _showScrollToBottom && mounted) { + if (showButton != _showScrollToBottom && mounted && !_isDeactivated) { setState(() { _showScrollToBottom = showButton; }); diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 631be8c..a5d9cad 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -169,6 +169,7 @@ class _ModernChatInputState extends ConsumerState StreamSubscription? _textSub; int _intensity = 0; // 0..10 from service String _baseTextAtStart = ''; + bool _isDeactivated = false; @override void initState() { @@ -185,26 +186,26 @@ class _ModernChatInputState extends ConsumerState vsync: this, ); - // Listen for prefilled text updates (e.g., from share intent) + // Apply any prefilled text on first frame (focus/expand handled via inputFocusTrigger) WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || _isDeactivated) return; final text = ref.read(prefilledInputTextProvider); if (text != null && text.isNotEmpty) { _controller.text = text; _controller.selection = TextSelection.collapsed(offset: text.length); // Clear after applying so it doesn't re-apply on rebuilds ref.read(prefilledInputTextProvider.notifier).state = null; - _ensureFocusedIfEnabled(); - if (!_isExpanded) _setExpanded(true); } }); + // Removed ref.listen here; it must be used from build in this Riverpod version + // Listen for text changes and update only when emptiness flips _controller.addListener(() { final has = _controller.text.trim().isNotEmpty; if (has != _hasText) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || _isDeactivated) return; setState(() => _hasText = has); // Intelligent expansion: expand when user starts typing if (has && !_isExpanded) { @@ -220,14 +221,14 @@ class _ModernChatInputState extends ConsumerState _blurCollapseTimer?.cancel(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || _isDeactivated) return; final hasFocus = _focusNode.hasFocus; if (hasFocus) { if (!_isExpanded) _setExpanded(true); } else { // Defer collapse slightly to avoid IME show/hide race conditions _blurCollapseTimer = Timer(const Duration(milliseconds: 160), () { - if (!mounted) return; + if (!mounted || _isDeactivated) return; if (_focusNode.hasFocus) return; // focus came back // Collapse only when keyboard is fully hidden to avoid flicker final keyboardVisible = @@ -246,7 +247,7 @@ class _ModernChatInputState extends ConsumerState // The TextField's autofocus: true should handle focus and keyboard automatically // Additionally, request focus after first frame to ensure reliability across platforms WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || _isDeactivated) return; if (!_hasAutoFocusedOnce && widget.enabled) { _ensureFocusedIfEnabled(); _hasAutoFocusedOnce = true; @@ -271,17 +272,33 @@ class _ModernChatInputState extends ConsumerState void _ensureFocusedIfEnabled() { if (!widget.enabled) return; if (!_focusNode.hasFocus) { - FocusScope.of(context).requestFocus(_focusNode); + // Use FocusNode directly to avoid depending on Inherited widgets + _focusNode.requestFocus(); } } + @override + void deactivate() { + _isDeactivated = true; + _blurCollapseTimer?.cancel(); + _expandController.stop(); + _pulseController.stop(); + super.deactivate(); + } + + @override + void activate() { + super.activate(); + _isDeactivated = false; + } + @override void didUpdateWidget(covariant ModernChatInput oldWidget) { super.didUpdateWidget(oldWidget); if (widget.enabled && !oldWidget.enabled && !_hasAutoFocusedOnce) { // Became enabled (e.g., after selecting a model) → focus the input WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || _isDeactivated) return; _ensureFocusedIfEnabled(); _hasAutoFocusedOnce = true; }); @@ -289,7 +306,7 @@ class _ModernChatInputState extends ConsumerState if (!widget.enabled && oldWidget.enabled) { // Became disabled → collapse and hide keyboard WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || _isDeactivated) return; if (_isExpanded) _setExpanded(false); if (_focusNode.hasFocus) { _focusNode.unfocus(); @@ -312,7 +329,7 @@ class _ModernChatInputState extends ConsumerState } void _setExpanded(bool expanded) { - if (_isExpanded == expanded) return; + if (!mounted || _isDeactivated || _isExpanded == expanded) return; setState(() { _isExpanded = expanded; }); @@ -325,6 +342,21 @@ class _ModernChatInputState extends ConsumerState @override Widget build(BuildContext context) { + // Listen for prefilled text changes safely from build + ref.listen(prefilledInputTextProvider, (previous, next) { + final incoming = next?.trim(); + if (incoming == null || incoming.isEmpty) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _isDeactivated) return; + _controller.text = incoming; + _controller.selection = + TextSelection.collapsed(offset: incoming.length); + try { + ref.read(prefilledInputTextProvider.notifier).state = null; + } catch (_) {} + }); + }); + // Check if assistant is currently generating by checking last assistant message streaming final messages = ref.watch(chatMessagesProvider); final isGenerating = @@ -345,7 +377,7 @@ class _ModernChatInputState extends ConsumerState // React to external focus requests (e.g., from share prefill) final focusTick = ref.watch(inputFocusTriggerProvider); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || _isDeactivated) return; if (focusTick > 0) { _ensureFocusedIfEnabled(); if (!_isExpanded) _setExpanded(true); diff --git a/lib/shared/theme/app_theme.dart b/lib/shared/theme/app_theme.dart index 195a395..4e0184b 100644 --- a/lib/shared/theme/app_theme.dart +++ b/lib/shared/theme/app_theme.dart @@ -375,6 +375,13 @@ class _AnimatedThemeWrapperState extends State super.dispose(); } + @override + void deactivate() { + // Pause animations during deactivation to avoid rebuilds in wrong build scope + _controller.stop(); + super.deactivate(); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( diff --git a/lib/shared/widgets/improved_loading_states.dart b/lib/shared/widgets/improved_loading_states.dart index 2aebdd6..528eee2 100644 --- a/lib/shared/widgets/improved_loading_states.dart +++ b/lib/shared/widgets/improved_loading_states.dart @@ -468,6 +468,13 @@ class _ShimmerLoaderState extends State super.dispose(); } + @override + void deactivate() { + // Pause shimmer during deactivation to avoid rebuilds in wrong build scope + _shimmerController.stop(); + super.deactivate(); + } + @override Widget build(BuildContext context) { final theme = context.conduitTheme; diff --git a/lib/shared/widgets/loading_states.dart b/lib/shared/widgets/loading_states.dart index 88d8631..9a27fc4 100644 --- a/lib/shared/widgets/loading_states.dart +++ b/lib/shared/widgets/loading_states.dart @@ -229,6 +229,13 @@ class _SkeletonLoaderState extends State<_SkeletonLoader> super.dispose(); } + @override + void deactivate() { + // Pause shimmer during deactivation to avoid rebuilds in wrong build scope + _controller.stop(); + super.deactivate(); + } + @override Widget build(BuildContext context) { return Container( diff --git a/lib/shared/widgets/skeleton_loader.dart b/lib/shared/widgets/skeleton_loader.dart index bf88020..c7e037d 100644 --- a/lib/shared/widgets/skeleton_loader.dart +++ b/lib/shared/widgets/skeleton_loader.dart @@ -55,6 +55,13 @@ class _SkeletonLoaderState extends State super.dispose(); } + @override + void deactivate() { + // Pause shimmer during deactivation to avoid rebuilds in wrong build scope + _controller.stop(); + super.deactivate(); + } + @override Widget build(BuildContext context) { return AnimatedBuilder(