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:
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user