feat: add composer autofocus management for improved chat input experience

- Introduced ComposerAutofocusEnabled provider to manage the auto-focus state of the chat composer, allowing for better control over user interactions.
- Updated ModernChatInput to respect the autofocus setting, ensuring the keyboard behavior aligns with user intent and context.
- Enhanced ChatPage to suppress auto-focus when opening the slide drawer, improving user experience during navigation.
- Refactored SlideDrawer to include an onOpenStart callback for dismissing the keyboard, ensuring a smoother transition when the drawer is opened.
This commit is contained in:
cogwheel0
2025-10-10 15:22:54 +05:30
parent fe1e03c198
commit e73c5ee93a
5 changed files with 94 additions and 16 deletions

View File

@@ -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<List<ChatMessage>> {
StreamingResponseController? _messageStream;

View File

@@ -67,6 +67,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
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<ChatPage> {
// When opening an existing conversation, start reading from the top
_shouldAutoScrollToBottom = false;
_resetScrollToTop();
_suppressKeepPinnedOnce = true;
}
}
@@ -788,6 +790,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
} 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<ChatPage> {
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<ChatPage> {
),
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(

View File

@@ -158,7 +158,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}
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<ModernChatInput>
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<ModernChatInput>
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(