diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index cca739f..50db961 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -75,6 +75,16 @@ class ComposerHasFocus extends _$ComposerHasFocus { void set(bool value) => state = value; } +// Whether the chat composer is allowed to auto-focus. +// When false, the composer will remain unfocused until the user taps it. +@Riverpod(keepAlive: true) +class ComposerAutofocusEnabled extends _$ComposerAutofocusEnabled { + @override + bool build() => true; + + void set(bool value) => state = value; +} + // Chat messages notifier class class ChatMessagesNotifier extends Notifier> { StreamingResponseController? _messageStream; diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index d6bbd61..bfe0494 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -67,6 +67,7 @@ class _ChatPageState extends ConsumerState { bool _shouldAutoScrollToBottom = true; bool _autoScrollCallbackScheduled = false; bool _pendingConversationScrollReset = false; + bool _suppressKeepPinnedOnce = false; // skip keep-pinned bottom after reset String? _cachedGreetingName; bool _greetingReady = false; @@ -780,6 +781,7 @@ class _ChatPageState extends ConsumerState { // When opening an existing conversation, start reading from the top _shouldAutoScrollToBottom = false; _resetScrollToTop(); + _suppressKeepPinnedOnce = true; } } @@ -788,6 +790,12 @@ class _ChatPageState extends ConsumerState { } else { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + if (_suppressKeepPinnedOnce) { + // Skip the one-time keep-pinned-to-bottom adjustment right after + // a conversation switch so we remain at the top. + _suppressKeepPinnedOnce = false; + return; + } const double keepPinnedThreshold = 60.0; final distanceFromBottom = _distanceFromBottom(); if (distanceFromBottom > 0 && @@ -1178,6 +1186,14 @@ class _ChatPageState extends ConsumerState { edgeFraction: edgeFraction, settleFraction: 0.06, // even gentler settle for instant open feel scrimColor: scrim, + onOpenStart: () { + // Suppress composer auto-focus once we unfocus for the drawer + try { + ref + .read(composerAutofocusEnabledProvider.notifier) + .set(false); + } catch (_) {} + }, drawer: SafeArea( top: true, bottom: true, @@ -1214,7 +1230,19 @@ class _ChatPageState extends ConsumerState { ), child: IconButton( onPressed: () { - // Open slide drawer + // Suppress auto-focus and dismiss keyboard, then open drawer + try { + ref + .read( + composerAutofocusEnabledProvider + .notifier, + ) + .set(false); + FocusManager.instance.primaryFocus?.unfocus(); + SystemChannels.textInput.invokeMethod( + 'TextInput.hide', + ); + } catch (_) {} SlideDrawer.of(ctx)?.open(); }, icon: Icon( diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 1625ed3..d486b16 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -158,7 +158,12 @@ class _ModernChatInputState extends ConsumerState } void _ensureFocusedIfEnabled() { - if (!widget.enabled || _focusNode.hasFocus || _pendingFocus) { + // Respect global suppression flag to avoid re-opening keyboard + final autofocusEnabled = ref.read(composerAutofocusEnabledProvider); + if (!widget.enabled || + _focusNode.hasFocus || + _pendingFocus || + !autofocusEnabled) { return; } @@ -629,7 +634,8 @@ class _ModernChatInputState extends ConsumerState final selectedToolIds = ref.watch(selectedToolIdsProvider); final focusTick = ref.watch(inputFocusTriggerProvider); - if (focusTick != _lastHandledFocusTick) { + final autofocusEnabled = ref.watch(composerAutofocusEnabledProvider); + if (autofocusEnabled && focusTick != _lastHandledFocusTick) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _isDeactivated) return; _ensureFocusedIfEnabled(); @@ -1010,6 +1016,10 @@ class _ModernChatInputState extends ConsumerState behavior: HitTestBehavior.opaque, onTap: () { if (!widget.enabled) return; + // Explicit user intent to focus: re-enable autofocus and focus + try { + ref.read(composerAutofocusEnabledProvider.notifier).set(true); + } catch (_) {} _ensureFocusedIfEnabled(); }, child: Semantics( diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 940d87c..d49b084 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -22,6 +22,7 @@ import '../../../core/utils/user_avatar_utils.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/widgets/user_avatar.dart'; import '../../../shared/widgets/model_avatar.dart'; +import '../../../shared/widgets/slide_drawer.dart'; import '../../../core/models/model.dart'; import '../../../core/models/conversation.dart'; import '../../../core/models/folder.dart'; @@ -1373,7 +1374,7 @@ class _ChatsDrawerState extends ConsumerState { Future _selectConversation(BuildContext context, String id) async { if (_isLoadingConversation) return; setState(() => _isLoadingConversation = true); - final navigator = Navigator.of(context); + // Keep a reference only if needed in the future; currently unused. // Capture a provider container detached from this widget's lifecycle so // we can continue to read/write providers after the drawer is closed. final container = ProviderScope.containerOf(context, listen: false); @@ -1386,15 +1387,9 @@ class _ChatsDrawerState extends ConsumerState { container.read(activeConversationProvider.notifier).clear(); container.read(chat.chatMessagesProvider.notifier).clearMessages(); - // Close the drawer immediately for faster perceived performance + // Close the slide drawer for faster perceived performance if (mounted) { - // Prefer closing the Scaffold's drawer to avoid popping other routes - final scaffold = Scaffold.maybeOf(context); - if (scaffold?.isDrawerOpen == true) { - scaffold!.closeDrawer(); - } else { - navigator.maybePop(); - } + SlideDrawer.of(context)?.close(); } // Load the full conversation details in the background diff --git a/lib/shared/widgets/slide_drawer.dart b/lib/shared/widgets/slide_drawer.dart index fa3f7c3..cb02c7a 100644 --- a/lib/shared/widgets/slide_drawer.dart +++ b/lib/shared/widgets/slide_drawer.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'dart:ui' as ui; import '../../shared/theme/theme_extensions.dart'; @@ -18,6 +19,8 @@ class SlideDrawer extends StatefulWidget { final double contentScaleDelta; // Max blur sigma applied to pushed content at full open. final double contentBlurSigma; + // Optional hook invoked right as opening begins (button or drag). + final VoidCallback? onOpenStart; const SlideDrawer({ super.key, @@ -32,6 +35,7 @@ class SlideDrawer extends StatefulWidget { this.pushContent = true, this.contentScaleDelta = 0.02, this.contentBlurSigma = 2.0, + this.onOpenStart, }); static SlideDrawerState? of(BuildContext context) => @@ -60,7 +64,11 @@ class SlideDrawerState extends State bool get isOpen => _controller.value == 1.0; - Future _animateTo(double target, {double velocity = 0.0}) async { + Future _animateTo( + double target, { + double velocity = 0.0, + bool? easeOut, + }) async { final current = _controller.value; final distance = (current - target).abs().clamp(0.0, 1.0); // Smooth, distance-based duration so snaps don't feel abrupt. @@ -70,7 +78,8 @@ class SlideDrawerState extends State final ms = (baseMs * distance / (1.0 + 1.5 * normSpeed)) .clamp(90, baseMs) .round(); - final curve = target > current + final bool useEaseOut = easeOut ?? (target > current); + final curve = useEaseOut ? (normSpeed > 0.5 ? Curves.linearToEaseOut : Curves.easeOutCubic) : (normSpeed > 0.5 ? Curves.easeInToLinear : Curves.easeInCubic); await _controller.animateTo( @@ -80,13 +89,39 @@ class SlideDrawerState extends State ); } - void open({double velocity = 0.0}) => _animateTo(1.0, velocity: velocity); - void close({double velocity = 0.0}) => _animateTo(0.0, velocity: velocity); + void open({double velocity = 0.0}) { + // Notify caller and dismiss keyboard before animating open + try { + widget.onOpenStart?.call(); + } catch (_) {} + _dismissKeyboard(); + _animateTo(1.0, velocity: velocity); + } + + void close({double velocity = 0.0}) => + _animateTo(0.0, velocity: velocity, easeOut: true); void toggle() => isOpen ? close() : open(); + void _dismissKeyboard() { + try { + FocusManager.instance.primaryFocus?.unfocus(); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } catch (_) { + // Best-effort: ignore platform channel errors. + } + } + double _startValue = 0.0; void _onDragStart(DragStartDetails d) { + // Let drags from the open state be interactive rather than snapping. + // If starting to open from the edge, dismiss any active keyboard + if (_controller.value <= 0.001) { + try { + widget.onOpenStart?.call(); + } catch (_) {} + _dismissKeyboard(); + } _startValue = _controller.value; }