From c78d1448b89561593da9666175f970a5f501be7a Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 8 Sep 2025 01:05:48 +0530 Subject: [PATCH] feat: enter to send option and one tap to focus keyboard --- lib/core/services/settings_service.dart | 26 ++ lib/features/chat/views/chat_page.dart | 69 +++- .../chat/widgets/modern_chat_input.dart | 309 ++++++++++++------ .../profile/views/app_customization_page.dart | 56 ++++ lib/shared/widgets/optimized_list.dart | 4 + 5 files changed, 356 insertions(+), 108 deletions(-) diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart index db9536c..84745c4 100644 --- a/lib/core/services/settings_service.dart +++ b/lib/core/services/settings_service.dart @@ -23,6 +23,8 @@ class SettingsService { static const String _socketTransportModeKey = 'socket_transport_mode'; // 'auto' or 'ws' // Quick pill visibility selections (max 2) static const String _quickPillsKey = 'quick_pills'; // StringList of identifiers e.g. ['web','image','tools'] + // Chat input behavior + static const String _sendOnEnterKey = 'send_on_enter'; /// Get reduced motion preference static Future getReduceMotion() async { @@ -139,6 +141,7 @@ class SettingsService { voiceAutoSendFinal: await getVoiceAutoSendFinal(), socketTransportMode: await getSocketTransportMode(), quickPills: await getQuickPills(), + sendOnEnter: await getSendOnEnter(), ); } @@ -158,6 +161,7 @@ class SettingsService { setVoiceAutoSendFinal(settings.voiceAutoSendFinal), setSocketTransportMode(settings.socketTransportMode), setQuickPills(settings.quickPills), + setSendOnEnter(settings.sendOnEnter), ]); } @@ -223,6 +227,17 @@ class SettingsService { await prefs.setStringList(_quickPillsKey, pills.take(2).toList()); } + // Chat input behavior + static Future getSendOnEnter() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_sendOnEnterKey) ?? false; + } + + static Future setSendOnEnter(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_sendOnEnterKey, value); + } + /// Get effective animation duration considering all settings static Duration getEffectiveAnimationDuration( BuildContext context, @@ -278,6 +293,7 @@ class AppSettings { final bool voiceAutoSendFinal; final String socketTransportMode; // 'auto' or 'ws' final List quickPills; // e.g., ['web','image'] + final bool sendOnEnter; const AppSettings({ this.reduceMotion = false, @@ -293,6 +309,7 @@ class AppSettings { this.voiceAutoSendFinal = false, this.socketTransportMode = 'ws', this.quickPills = const [], + this.sendOnEnter = false, }); AppSettings copyWith({ @@ -309,6 +326,7 @@ class AppSettings { bool? voiceAutoSendFinal, String? socketTransportMode, List? quickPills, + bool? sendOnEnter, }) { return AppSettings( reduceMotion: reduceMotion ?? this.reduceMotion, @@ -324,6 +342,7 @@ class AppSettings { voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal, socketTransportMode: socketTransportMode ?? this.socketTransportMode, quickPills: quickPills ?? this.quickPills, + sendOnEnter: sendOnEnter ?? this.sendOnEnter, ); } @@ -342,6 +361,7 @@ class AppSettings { other.voiceLocaleId == voiceLocaleId && other.voiceHoldToTalk == voiceHoldToTalk && other.voiceAutoSendFinal == voiceAutoSendFinal && + other.sendOnEnter == sendOnEnter && _listEquals(other.quickPills, quickPills); // socketTransportMode intentionally not included in == to avoid frequent rebuilds } @@ -361,6 +381,7 @@ class AppSettings { voiceHoldToTalk, voiceAutoSendFinal, socketTransportMode, + sendOnEnter, Object.hashAllUnordered(quickPills), ); } @@ -458,6 +479,11 @@ class AppSettingsNotifier extends StateNotifier { await SettingsService.setQuickPills(filtered); } + Future setSendOnEnter(bool value) async { + state = state.copyWith(sendOnEnter: value); + await SettingsService.setSendOnEnter(value); + } + Future resetToDefaults() async { const defaultSettings = AppSettings(); await SettingsService.saveSettings(defaultSettings); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index e7a585e..4004333 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -56,6 +56,8 @@ class _ChatPageState extends ConsumerState { Timer? _scrollDebounceTimer; bool _isDeactivated = false; double _inputHeight = 0; // dynamic input height to position scroll button + bool _lastKeyboardVisible = false; // track keyboard visibility transitions + bool _didStartupFocus = false; // one-time auto-focus on startup String _formatModelDisplayName( String name, { @@ -607,7 +609,10 @@ class _ChatPageState extends ConsumerState { Widget _buildLoadingMessagesList() { return ListView.builder( key: const ValueKey('loading_messages'), - controller: _scrollController, + // Do not reuse the primary scroll controller here to avoid + // attaching the same controller to multiple lists during + // AnimatedSwitcher transitions. + controller: null, padding: const EdgeInsets.fromLTRB( Spacing.lg, Spacing.md, @@ -929,6 +934,21 @@ class _ChatPageState extends ConsumerState { final canScroll = _scrollController.hasClients && _scrollController.position.maxScrollExtent > 0; + // On keyboard open, if already near bottom, auto-scroll to bottom to keep input visible + if (keyboardVisible && !_lastKeyboardVisible) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_scrollController.hasClients) { + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + if (maxScroll - currentScroll < 300) { + _scrollToBottom(smooth: true); + } + } + }); + } + _lastKeyboardVisible = keyboardVisible; + // Auto-select model when in reviewer mode with no selection if (isReviewerMode && selectedModel == null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -936,12 +956,36 @@ class _ChatPageState extends ConsumerState { }); } + // Focus composer on app startup once, when a model is selected + if (!_didStartupFocus && selectedModel != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final current = ref.read(inputFocusTriggerProvider); + // Immediate focus bump + ref.read(inputFocusTriggerProvider.notifier).state = current + 1; + // Second bump shortly after to overcome route/IME timing + Future.delayed(const Duration(milliseconds: 120), () { + if (!mounted) return; + final cur2 = ref.read(inputFocusTriggerProvider); + ref.read(inputFocusTriggerProvider.notifier).state = cur2 + 1; + }); + }); + _didStartupFocus = true; + } + return ErrorBoundary( child: PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { if (didPop) return; + // First, if any input has focus, clear focus and consume back press + final currentFocus = FocusManager.instance.primaryFocus; + if (currentFocus != null && currentFocus.hasFocus) { + currentFocus.unfocus(); + return; + } + // Auto-handle leaving without confirmation final messages = ref.read(chatMessagesProvider); final isStreaming = messages.any((msg) => msg.isStreaming); @@ -1312,8 +1356,16 @@ class _ChatPageState extends ConsumerState { ], ], ), - body: Stack( - children: [ + body: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + FocusManager.instance.primaryFocus?.unfocus(); + try { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } catch (_) {} + }, + child: Stack( + children: [ Column( children: [ // Messages Area with pull-to-refresh @@ -1347,8 +1399,12 @@ class _ChatPageState extends ConsumerState { }, child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => - FocusManager.instance.primaryFocus?.unfocus(), + onTap: () { + FocusManager.instance.primaryFocus?.unfocus(); + try { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } catch (_) {} + }, child: RepaintBoundary( child: _buildMessagesList(theme), ), @@ -1462,7 +1518,8 @@ class _ChatPageState extends ConsumerState { ), ), // Edge overlay removed; rely on native interactive drawer drag - ], + ], + ), ), ), // Scaffold ), // PopScope diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 24801ce..31c0629 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -21,6 +21,14 @@ import '../../chat/services/voice_input_service.dart'; import '../../../shared/utils/platform_utils.dart'; import 'package:conduit/l10n/app_localizations.dart'; +class _SendMessageIntent extends Intent { + const _SendMessageIntent(); +} + +class _InsertNewlineIntent extends Intent { + const _InsertNewlineIntent(); +} + class ModernChatInput extends ConsumerStatefulWidget { final Function(String) onSendMessage; final bool enabled; @@ -166,7 +174,7 @@ class _ModernChatInputState extends ConsumerState late AnimationController _expandController; late AnimationController _pulseController; Timer? _blurCollapseTimer; - bool _hasAutoFocusedOnce = false; + bool _pendingFocusAfterExpand = false; late VoiceInputService _voiceService; StreamSubscription? _intensitySub; StreamSubscription? _textSub; @@ -185,6 +193,21 @@ class _ModernChatInputState extends ConsumerState vsync: this, value: 1.0, // Start expanded ); + _expandController.addStatusListener((status) { + if (!mounted || _isDeactivated) return; + if (_pendingFocusAfterExpand && status == AnimationStatus.completed) { + _pendingFocusAfterExpand = false; + // Focus and ensure IME shows reliably after expansion finishes + _ensureFocusedIfEnabled(); + Future.microtask(() { + try { + if (_focusNode.hasFocus) { + SystemChannels.textInput.invokeMethod('TextInput.show'); + } + } catch (_) {} + }); + } + }); _pulseController = AnimationController( duration: AnimationDuration.slow, vsync: this, @@ -230,6 +253,8 @@ class _ModernChatInputState extends ConsumerState if (hasFocus) { if (!_isExpanded) _setExpanded(true); } else { + // A blur occurred: ensure no pending auto-focus remains + _pendingFocusAfterExpand = false; // Defer collapse slightly to avoid IME show/hide race conditions _blurCollapseTimer = Timer(const Duration(milliseconds: 160), () { if (!mounted || _isDeactivated) return; @@ -247,16 +272,7 @@ class _ModernChatInputState extends ConsumerState }); }); - // Let autofocus handle the focus - no manual intervention - // 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 || _isDeactivated) return; - if (!_hasAutoFocusedOnce && widget.enabled) { - _ensureFocusedIfEnabled(); - _hasAutoFocusedOnce = true; - } - }); + // Do not auto-focus on mount; only focus on explicit user intent } @override @@ -299,14 +315,7 @@ class _ModernChatInputState extends ConsumerState @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 || _isDeactivated) return; - _ensureFocusedIfEnabled(); - _hasAutoFocusedOnce = true; - }); - } + // Avoid auto-focusing when becoming enabled; wait for user intent if (!widget.enabled && oldWidget.enabled) { // Became disabled → collapse and hide keyboard WidgetsBinding.instance.addPostFrameCallback((_) { @@ -326,12 +335,7 @@ class _ModernChatInputState extends ConsumerState PlatformUtils.lightHaptic(); widget.onSendMessage(text); _controller.clear(); - // After sending, dismiss keyboard and collapse input - if (_focusNode.hasFocus) { - _focusNode.unfocus(); - } - // Ensure UI reflects empty state and collapses - _setExpanded(false); + // Keep focus and keyboard open; do not collapse automatically } void _setExpanded(bool expanded) { @@ -346,6 +350,23 @@ class _ModernChatInputState extends ConsumerState } } + void _insertNewline() { + final text = _controller.text; + TextSelection sel = _controller.selection; + final int start = sel.isValid ? sel.start : text.length; + final int end = sel.isValid ? sel.end : text.length; + final String before = text.substring(0, start); + final String after = text.substring(end); + final String updated = '$before\n$after'; + _controller.value = TextEditingValue( + text: updated, + selection: TextSelection.collapsed(offset: before.length + 1), + composing: TextRange.empty, + ); + // Ensure field stays focused + _ensureFocusedIfEnabled(); + } + @override Widget build(BuildContext context) { // Listen for prefilled text changes safely from build @@ -376,6 +397,7 @@ class _ModernChatInputState extends ConsumerState final imageGenAvailable = ref.watch(imageGenerationAvailableProvider); final selectedQuickPills = ref.watch(appSettingsProvider.select((s) => s.quickPills)); + final sendOnEnter = ref.watch(appSettingsProvider.select((s) => s.sendOnEnter)); final toolsAsync = ref.watch(toolsListProvider); final List availableTools = toolsAsync.maybeWhen>( data: (t) => t, @@ -389,18 +411,22 @@ class _ModernChatInputState extends ConsumerState orElse: () => false, ); - // React to external focus requests (e.g., from share prefill) + // React to external focus requests (e.g., from share prefill or startup) final focusTick = ref.watch(inputFocusTriggerProvider); if (focusTick != _lastHandledFocusTick) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _isDeactivated) return; - // Do not steal focus if another input currently has primary focus - final currentFocus = FocusManager.instance.primaryFocus; - final anotherHasFocus = currentFocus != null && currentFocus != _focusNode; - if (!anotherHasFocus) { - _ensureFocusedIfEnabled(); - if (!_isExpanded) _setExpanded(true); - } + // Explicit request: always try to focus and show the keyboard + _ensureFocusedIfEnabled(); + if (!_isExpanded) _setExpanded(true); + // Nudge the platform text input to show reliably on iOS/Android + Future.microtask(() { + try { + if (_focusNode.hasFocus) { + SystemChannels.textInput.invokeMethod('TextInput.show'); + } + } catch (_) {} + }); _lastHandledFocusTick = focusTick; }); } @@ -468,12 +494,31 @@ class _ModernChatInputState extends ConsumerState top: Spacing.inputPadding, bottom: Spacing.inputPadding, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (!_isExpanded) ...[ - _buildRoundButton( - icon: Icons.add, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (!_isExpanded && widget.enabled) { + _pendingFocusAfterExpand = true; + _setExpanded(true); + WidgetsBinding.instance + .addPostFrameCallback((_) { + if (!mounted) return; + if (_pendingFocusAfterExpand) { + _ensureFocusedIfEnabled(); + try { + SystemChannels.textInput + .invokeMethod('TextInput.show'); + } catch (_) {} + } + }); + } + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!_isExpanded) ...[ + _buildRoundButton( + icon: Icons.add, onTap: widget.enabled ? _showAttachmentOptions : null, @@ -497,79 +542,126 @@ class _ModernChatInputState extends ConsumerState hint: AppLocalizations.of( context, )!.messageInputHint, - child: TextField( - controller: _controller, - focusNode: _focusNode, - enabled: widget.enabled, - autofocus: false, - maxLines: _isExpanded ? null : 1, - keyboardType: TextInputType.multiline, - textCapitalization: - TextCapitalization.sentences, - textInputAction: TextInputAction.newline, - showCursor: true, - cursorColor: - context.conduitTheme.inputText, - style: AppTypography.chatMessageStyle - .copyWith( - color: _isRecording - ? context - .conduitTheme - .inputPlaceholder - : context - .conduitTheme - .inputText, - fontStyle: _isRecording - ? FontStyle.italic - : FontStyle.normal, - fontWeight: _isRecording - ? FontWeight.w500 - : FontWeight.w400, + child: Shortcuts( + shortcuts: () { + final map = { + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): const _SendMessageIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): const _SendMessageIntent(), + }; + if (sendOnEnter) { + map[LogicalKeySet(LogicalKeyboardKey.enter)] = const _SendMessageIntent(); + map[LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.enter)] = const _InsertNewlineIntent(); + } + return map; + }(), + child: Actions( + actions: >{ + _SendMessageIntent: CallbackAction<_SendMessageIntent>( + onInvoke: (intent) { + _sendMessage(); + return null; + }, ), - decoration: InputDecoration( - hintText: AppLocalizations.of( - context, - )!.messageHintText, - hintStyle: TextStyle( - color: context - .conduitTheme - .inputPlaceholder, - fontSize: AppTypography.bodyLarge, - fontWeight: _isRecording - ? FontWeight.w500 - : FontWeight.w400, - fontStyle: _isRecording - ? FontStyle.italic - : FontStyle.normal, - ), - // Ensure the text field background matches its parent container - // and does not use the global InputDecorationTheme fill - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true, - alignLabelWithHint: true, - ), - // Removed onChanged setState to reduce rebuilds - onSubmitted: (_) => _sendMessage(), + _InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>( + onInvoke: (intent) { + _insertNewline(); + return null; + }, + ), + }, + child: TextField( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + autofocus: false, + maxLines: _isExpanded ? null : 1, + keyboardType: TextInputType.multiline, + textCapitalization: + TextCapitalization.sentences, + textInputAction: sendOnEnter + ? TextInputAction.send + : TextInputAction.newline, + showCursor: true, + scrollPadding: const EdgeInsets.only(bottom: 80), + keyboardAppearance: Theme.of(context).brightness, + cursorColor: + context.conduitTheme.inputText, + style: AppTypography.chatMessageStyle + .copyWith( + color: _isRecording + ? context + .conduitTheme + .inputPlaceholder + : context + .conduitTheme + .inputText, + fontStyle: _isRecording + ? FontStyle.italic + : FontStyle.normal, + fontWeight: _isRecording + ? FontWeight.w500 + : FontWeight.w400, + ), + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.messageHintText, + hintStyle: TextStyle( + color: context + .conduitTheme + .inputPlaceholder, + fontSize: AppTypography.bodyLarge, + fontWeight: _isRecording + ? FontWeight.w500 + : FontWeight.w400, + fontStyle: _isRecording + ? FontStyle.italic + : FontStyle.normal, + ), + // Ensure the text field background matches its parent container + // and does not use the global InputDecorationTheme fill + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + alignLabelWithHint: true, + ), + // Send on Enter when enabled; otherwise keep newline behavior + onSubmitted: (_) { + if (sendOnEnter) _sendMessage(); + }, onTap: () { if (!widget.enabled) return; if (!_isExpanded) { + _pendingFocusAfterExpand = true; _setExpanded(true); + // Fallback in case animation is skipped WidgetsBinding.instance .addPostFrameCallback((_) { - if (!mounted) return; - _ensureFocusedIfEnabled(); - }); + if (!mounted) return; + if (_pendingFocusAfterExpand) { + _ensureFocusedIfEnabled(); + try { + SystemChannels.textInput + .invokeMethod('TextInput.show'); + } catch (_) {} + } + }); } else { _ensureFocusedIfEnabled(); + try { + SystemChannels.textInput + .invokeMethod('TextInput.show'); + } catch (_) {} } }, ), + ), + ), ), ), if (!_isExpanded) ...[ @@ -582,6 +674,7 @@ class _ModernChatInputState extends ConsumerState ), ], ], + ), ), ), @@ -1192,6 +1285,8 @@ class _ModernChatInputState extends ConsumerState void _showAttachmentOptions() { HapticFeedback.selectionClick(); + final prevCanRequest = _focusNode.canRequestFocus; + _focusNode.canRequestFocus = false; showModalBottomSheet( context: context, backgroundColor: Colors.transparent, @@ -1263,16 +1358,26 @@ class _ModernChatInputState extends ConsumerState ], ), ), - ); + ).whenComplete(() { + if (mounted) { + _focusNode.canRequestFocus = prevCanRequest; + } + }); } void _showUnifiedToolsModal() { HapticFeedback.selectionClick(); + final prevCanRequest = _focusNode.canRequestFocus; + _focusNode.canRequestFocus = false; showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (context) => const UnifiedToolsModal(), - ); + ).whenComplete(() { + if (mounted) { + _focusNode.canRequestFocus = prevCanRequest; + } + }); } // --- Inline Voice Input --- diff --git a/lib/features/profile/views/app_customization_page.dart b/lib/features/profile/views/app_customization_page.dart index c7dd129..e59657c 100644 --- a/lib/features/profile/views/app_customization_page.dart +++ b/lib/features/profile/views/app_customization_page.dart @@ -387,6 +387,62 @@ class AppCustomizationPage extends ConsumerWidget { }, ), + const SizedBox(height: Spacing.lg), + // Chat input behavior + Text( + 'Chat', + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + const SizedBox(height: Spacing.md), + ConduitCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.listItemPadding, + vertical: Spacing.sm, + ), + leading: Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary + .withValues(alpha: Alpha.highlight), + borderRadius: BorderRadius.circular(AppBorderRadius.small), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.paperplane + : Icons.keyboard_return, + color: context.conduitTheme.buttonPrimary, + size: IconSize.medium, + ), + ), + title: Text( + 'Send on Enter', + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + 'Enter sends (soft keyboard). Cmd/Ctrl+Enter also available', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + trailing: Switch.adaptive( + value: settings.sendOnEnter, + onChanged: (v) => + ref.read(appSettingsProvider.notifier).setSendOnEnter(v), + ), + ), + ], + ), + ), + const SizedBox(height: Spacing.lg), Text( AppLocalizations.of(context)!.realtime, diff --git a/lib/shared/widgets/optimized_list.dart b/lib/shared/widgets/optimized_list.dart index 7a6c13d..06045cf 100644 --- a/lib/shared/widgets/optimized_list.dart +++ b/lib/shared/widgets/optimized_list.dart @@ -28,6 +28,7 @@ class OptimizedList extends ConsumerStatefulWidget { final bool addRepaintBoundaries; final bool enablePagination; final double paginationThreshold; + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; const OptimizedList({ super.key, @@ -53,6 +54,7 @@ class OptimizedList extends ConsumerStatefulWidget { this.addRepaintBoundaries = true, this.enablePagination = false, this.paginationThreshold = 0.8, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, }); @override @@ -142,6 +144,7 @@ class _OptimizedListState extends ConsumerState> { controller: _scrollController, padding: widget.padding, physics: widget.physics ?? const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: widget.keyboardDismissBehavior, shrinkWrap: widget.shrinkWrap, scrollDirection: widget.scrollDirection, reverse: widget.reverse, @@ -163,6 +166,7 @@ class _OptimizedListState extends ConsumerState> { controller: _scrollController, padding: widget.padding, physics: widget.physics ?? const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: widget.keyboardDismissBehavior, shrinkWrap: widget.shrinkWrap, scrollDirection: widget.scrollDirection, reverse: widget.reverse,