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; 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 // Chat messages notifier class
class ChatMessagesNotifier extends Notifier<List<ChatMessage>> { class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
StreamingResponseController? _messageStream; StreamingResponseController? _messageStream;

View File

@@ -67,6 +67,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
bool _shouldAutoScrollToBottom = true; bool _shouldAutoScrollToBottom = true;
bool _autoScrollCallbackScheduled = false; bool _autoScrollCallbackScheduled = false;
bool _pendingConversationScrollReset = false; bool _pendingConversationScrollReset = false;
bool _suppressKeepPinnedOnce = false; // skip keep-pinned bottom after reset
String? _cachedGreetingName; String? _cachedGreetingName;
bool _greetingReady = false; bool _greetingReady = false;
@@ -780,6 +781,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// When opening an existing conversation, start reading from the top // When opening an existing conversation, start reading from the top
_shouldAutoScrollToBottom = false; _shouldAutoScrollToBottom = false;
_resetScrollToTop(); _resetScrollToTop();
_suppressKeepPinnedOnce = true;
} }
} }
@@ -788,6 +790,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
} else { } else {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; 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; const double keepPinnedThreshold = 60.0;
final distanceFromBottom = _distanceFromBottom(); final distanceFromBottom = _distanceFromBottom();
if (distanceFromBottom > 0 && if (distanceFromBottom > 0 &&
@@ -1178,6 +1186,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
edgeFraction: edgeFraction, edgeFraction: edgeFraction,
settleFraction: 0.06, // even gentler settle for instant open feel settleFraction: 0.06, // even gentler settle for instant open feel
scrimColor: scrim, scrimColor: scrim,
onOpenStart: () {
// Suppress composer auto-focus once we unfocus for the drawer
try {
ref
.read(composerAutofocusEnabledProvider.notifier)
.set(false);
} catch (_) {}
},
drawer: SafeArea( drawer: SafeArea(
top: true, top: true,
bottom: true, bottom: true,
@@ -1214,7 +1230,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
child: IconButton( child: IconButton(
onPressed: () { 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(); SlideDrawer.of(ctx)?.open();
}, },
icon: Icon( icon: Icon(

View File

@@ -158,7 +158,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} }
void _ensureFocusedIfEnabled() { 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; return;
} }
@@ -629,7 +634,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final selectedToolIds = ref.watch(selectedToolIdsProvider); final selectedToolIds = ref.watch(selectedToolIdsProvider);
final focusTick = ref.watch(inputFocusTriggerProvider); final focusTick = ref.watch(inputFocusTriggerProvider);
if (focusTick != _lastHandledFocusTick) { final autofocusEnabled = ref.watch(composerAutofocusEnabledProvider);
if (autofocusEnabled && focusTick != _lastHandledFocusTick) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
@@ -1010,6 +1016,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () { onTap: () {
if (!widget.enabled) return; if (!widget.enabled) return;
// Explicit user intent to focus: re-enable autofocus and focus
try {
ref.read(composerAutofocusEnabledProvider.notifier).set(true);
} catch (_) {}
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
}, },
child: Semantics( child: Semantics(

View File

@@ -22,6 +22,7 @@ import '../../../core/utils/user_avatar_utils.dart';
import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/utils/conversation_context_menu.dart';
import '../../../shared/widgets/user_avatar.dart'; import '../../../shared/widgets/user_avatar.dart';
import '../../../shared/widgets/model_avatar.dart'; import '../../../shared/widgets/model_avatar.dart';
import '../../../shared/widgets/slide_drawer.dart';
import '../../../core/models/model.dart'; import '../../../core/models/model.dart';
import '../../../core/models/conversation.dart'; import '../../../core/models/conversation.dart';
import '../../../core/models/folder.dart'; import '../../../core/models/folder.dart';
@@ -1373,7 +1374,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
Future<void> _selectConversation(BuildContext context, String id) async { Future<void> _selectConversation(BuildContext context, String id) async {
if (_isLoadingConversation) return; if (_isLoadingConversation) return;
setState(() => _isLoadingConversation = true); 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 // Capture a provider container detached from this widget's lifecycle so
// we can continue to read/write providers after the drawer is closed. // we can continue to read/write providers after the drawer is closed.
final container = ProviderScope.containerOf(context, listen: false); final container = ProviderScope.containerOf(context, listen: false);
@@ -1386,15 +1387,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
container.read(activeConversationProvider.notifier).clear(); container.read(activeConversationProvider.notifier).clear();
container.read(chat.chatMessagesProvider.notifier).clearMessages(); container.read(chat.chatMessagesProvider.notifier).clearMessages();
// Close the drawer immediately for faster perceived performance // Close the slide drawer for faster perceived performance
if (mounted) { if (mounted) {
// Prefer closing the Scaffold's drawer to avoid popping other routes SlideDrawer.of(context)?.close();
final scaffold = Scaffold.maybeOf(context);
if (scaffold?.isDrawerOpen == true) {
scaffold!.closeDrawer();
} else {
navigator.maybePop();
}
} }
// Load the full conversation details in the background // Load the full conversation details in the background

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import '../../shared/theme/theme_extensions.dart'; import '../../shared/theme/theme_extensions.dart';
@@ -18,6 +19,8 @@ class SlideDrawer extends StatefulWidget {
final double contentScaleDelta; final double contentScaleDelta;
// Max blur sigma applied to pushed content at full open. // Max blur sigma applied to pushed content at full open.
final double contentBlurSigma; final double contentBlurSigma;
// Optional hook invoked right as opening begins (button or drag).
final VoidCallback? onOpenStart;
const SlideDrawer({ const SlideDrawer({
super.key, super.key,
@@ -32,6 +35,7 @@ class SlideDrawer extends StatefulWidget {
this.pushContent = true, this.pushContent = true,
this.contentScaleDelta = 0.02, this.contentScaleDelta = 0.02,
this.contentBlurSigma = 2.0, this.contentBlurSigma = 2.0,
this.onOpenStart,
}); });
static SlideDrawerState? of(BuildContext context) => static SlideDrawerState? of(BuildContext context) =>
@@ -60,7 +64,11 @@ class SlideDrawerState extends State<SlideDrawer>
bool get isOpen => _controller.value == 1.0; bool get isOpen => _controller.value == 1.0;
Future<void> _animateTo(double target, {double velocity = 0.0}) async { Future<void> _animateTo(
double target, {
double velocity = 0.0,
bool? easeOut,
}) async {
final current = _controller.value; final current = _controller.value;
final distance = (current - target).abs().clamp(0.0, 1.0); final distance = (current - target).abs().clamp(0.0, 1.0);
// Smooth, distance-based duration so snaps don't feel abrupt. // Smooth, distance-based duration so snaps don't feel abrupt.
@@ -70,7 +78,8 @@ class SlideDrawerState extends State<SlideDrawer>
final ms = (baseMs * distance / (1.0 + 1.5 * normSpeed)) final ms = (baseMs * distance / (1.0 + 1.5 * normSpeed))
.clamp(90, baseMs) .clamp(90, baseMs)
.round(); .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.linearToEaseOut : Curves.easeOutCubic)
: (normSpeed > 0.5 ? Curves.easeInToLinear : Curves.easeInCubic); : (normSpeed > 0.5 ? Curves.easeInToLinear : Curves.easeInCubic);
await _controller.animateTo( await _controller.animateTo(
@@ -80,13 +89,39 @@ class SlideDrawerState extends State<SlideDrawer>
); );
} }
void open({double velocity = 0.0}) => _animateTo(1.0, velocity: velocity); void open({double velocity = 0.0}) {
void close({double velocity = 0.0}) => _animateTo(0.0, velocity: velocity); // 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 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; double _startValue = 0.0;
void _onDragStart(DragStartDetails d) { 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; _startValue = _controller.value;
} }