From f169ff5feb4f39ad44116cd3fb74e9bffa41f805 Mon Sep 17 00:00:00 2001 From: dab246 Date: Tue, 29 Jul 2025 03:45:51 +0700 Subject: [PATCH] TF-3911 Support keyboard shortcut actions `ESC` to unfocus search input on web Signed-off-by: dab246 --- .../base/shortcut/app_shortcut_manager.dart | 11 ++ .../search/search_action_shortcut_type.dart | 4 + .../controller/search_controller.dart | 9 + ...e_keyboard_shortcut_actions_extension.dart | 47 +++++ .../widgets/search_input_form_widget.dart | 174 ++++++++++-------- 5 files changed, 164 insertions(+), 81 deletions(-) create mode 100644 lib/features/base/shortcut/search/search_action_shortcut_type.dart create mode 100644 lib/features/mailbox_dashboard/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart diff --git a/lib/features/base/shortcut/app_shortcut_manager.dart b/lib/features/base/shortcut/app_shortcut_manager.dart index 9762f5e56..a440a5271 100644 --- a/lib/features/base/shortcut/app_shortcut_manager.dart +++ b/lib/features/base/shortcut/app_shortcut_manager.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:tmail_ui_user/features/base/extensions/logical_key_set_helper.dart'; import 'package:tmail_ui_user/features/base/shortcut/mail/mail_list_action_shortcut_type.dart'; import 'package:tmail_ui_user/features/base/shortcut/mail/mail_view_action_shortcut_type.dart'; +import 'package:tmail_ui_user/features/base/shortcut/search/search_action_shortcut_type.dart'; class AppShortcutManager { static MailViewActionShortcutType? getMailViewActionFromEvent(KeyEvent event) { @@ -42,4 +43,14 @@ class AppShortcutManager { return null; } } + + static SearchActionShortcutType? getSearchActionFromEvent(KeyEvent event) { + final keysPressed = HardwareKeyboard.instance.logicalKeysPressed; + log('AppShortcutManager::getSearchActionFromEvent: Keys pressed: $keysPressed'); + if (keysPressed.isOnly(LogicalKeyboardKey.escape)) { + return SearchActionShortcutType.unFocus; + } else { + return null; + } + } } diff --git a/lib/features/base/shortcut/search/search_action_shortcut_type.dart b/lib/features/base/shortcut/search/search_action_shortcut_type.dart new file mode 100644 index 000000000..1f1749793 --- /dev/null +++ b/lib/features/base/shortcut/search/search_action_shortcut_type.dart @@ -0,0 +1,4 @@ + +enum SearchActionShortcutType { + unFocus; +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart index 07be9e129..6a2ddf2d0 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart @@ -27,6 +27,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/quick_sear import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/get_all_recent_search_latest_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/quick_search_email_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/save_recent_search_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/quick_search_filter.dart'; @@ -52,6 +53,7 @@ class SearchController extends BaseController with DateRangePickerMixin { SearchQuery? get searchQuery => searchEmailFilter.value.text; FocusNode searchFocus = FocusNode(); + FocusNode? keyboardFocusNode; String currentSearchText = ''; SearchController( @@ -64,11 +66,17 @@ class SearchController extends BaseController with DateRangePickerMixin { void onInit() { super.onInit(); searchFocus.addListener(_onSearchFocusChanged); + onKeyboardShortcutInit(); } void _onSearchFocusChanged() { log('SearchController::_onSearchFocusChanged: ${searchFocus.hasFocus}'); isSearchInputFocused.value = searchFocus.hasFocus; + if (searchFocus.hasFocus) { + refocusKeyboardShortcutFocus(); + } else { + clearKeyboardShortcutFocus(); + } } void openAdvanceSearch() { @@ -336,6 +344,7 @@ class SearchController extends BaseController with DateRangePickerMixin { searchInputController.dispose(); searchFocus.removeListener(_onSearchFocusChanged); searchFocus.dispose(); + onKeyboardShortcutDispose(); super.onClose(); } } diff --git a/lib/features/mailbox_dashboard/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart b/lib/features/mailbox_dashboard/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart new file mode 100644 index 000000000..f485767e5 --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart @@ -0,0 +1,47 @@ + +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/platform_info.dart'; +import 'package:flutter/material.dart' hide SearchController; +import 'package:tmail_ui_user/features/base/shortcut/app_shortcut_manager.dart'; +import 'package:tmail_ui_user/features/base/shortcut/search/search_action_shortcut_type.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/search_controller.dart'; + +extension HandleKeyboardShortcutActionsExtension on SearchController { + void onKeyboardShortcutInit() { + if (PlatformInfo.isWeb) { + keyboardFocusNode = FocusNode(); + } + } + + void onKeyboardShortcutDispose() { + if (PlatformInfo.isWeb) { + keyboardFocusNode?.dispose(); + } + } + + void clearKeyboardShortcutFocus() { + if (keyboardFocusNode?.hasFocus == true) { + keyboardFocusNode?.unfocus(); + } + } + + void refocusKeyboardShortcutFocus() { + if (keyboardFocusNode?.hasFocus == false) { + keyboardFocusNode?.requestFocus(); + } + } + + void onKeyDownEventAction(KeyEvent event) { + final shortcutType = AppShortcutManager.getSearchActionFromEvent(event); + log('$runtimeType::onKeyDownEventAction:🔥Shortcut triggered: $shortcutType'); + if (shortcutType == null) return; + handleSearchShortcutAction(shortcutType); + } + + void handleSearchShortcutAction(SearchActionShortcutType shortcutType) { + log('$runtimeType::handleSearchShortcutAction: 🔥 Shortcut triggered: $shortcutType'); + if (searchFocus.hasFocus) { + searchFocus.unfocus(); + } + } +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/widgets/search_input_form_widget.dart b/lib/features/mailbox_dashboard/presentation/widgets/search_input_form_widget.dart index d550b1809..3cc14f3fc 100644 --- a/lib/features/mailbox_dashboard/presentation/widgets/search_input_form_widget.dart +++ b/lib/features/mailbox_dashboard/presentation/widgets/search_input_form_widget.dart @@ -20,10 +20,12 @@ import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/email/presentation_email.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:tmail_ui_user/features/base/mixin/app_loader_mixin.dart'; +import 'package:tmail_ui_user/features/base/widget/keyboard/keyboard_handler_wrapper.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/model/recent_search.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/dashboard_action.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/search_controller.dart' as search; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/quick_search_filter.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/advanced_search/advanced_search_filter_overlay.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/advanced_search/icon_open_advanced_search_widget.dart'; @@ -52,6 +54,96 @@ class SearchInputFormWidget extends StatelessWidget with AppLoaderMixin { @override Widget build(BuildContext context) { return Obx(() { + Widget searchInputForm = QuickSearchInputForm( + maxHeight: 52, + suggestionsBoxVerticalOffset: 0.0, + minInputLengthAutocomplete: _dashBoardController.minInputLengthAutocomplete, + textFieldConfiguration: _createConfiguration(context), + suggestionsBoxDecoration: const QuickSearchSuggestionsBoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + debounceDuration: const Duration(milliseconds: 300), + listActionButton: const [ + QuickSearchFilter.hasAttachment, + QuickSearchFilter.last7Days, + QuickSearchFilter.fromMe, + QuickSearchFilter.starred, + ], + actionButtonBuilder: (context, filterAction, suggestionsListState) { + if (filterAction is QuickSearchFilter) { + return buildListButtonForQuickSearchForm( + context, + filterAction, + suggestionsListState); + } else { + return const SizedBox.shrink(); + } + }, + buttonActionCallback: (filterAction) { + if (filterAction is QuickSearchFilter) { + _searchController.addQuickSearchFilterToSuggestionSearchView(filterAction); + } + }, + listActionPadding: const EdgeInsets.only(left: 12, right: 12, top: 12, bottom: 6), + titleHeaderRecent: Padding( + padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8, top: 12), + child: Text( + AppLocalizations.of(context).recent, + style: ThemeUtils.defaultTextStyleInterFont.copyWith( + fontSize: 13.0, + color: AppColor.colorTextButtonHeaderThread, + fontWeight: FontWeight.w500 + ) + ) + ), + buttonShowAllResult: (context, keyword, _) { + if (keyword is String) { + return _buildShowAllResultButton(context, keyword); + } else { + return const SizedBox.shrink(); + } + }, + loadingBuilder: (context) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: loadingWidget + ), + fetchRecentActionCallback: (pattern) { + final accountId = _dashBoardController.accountId.value; + final userName = _dashBoardController.sessionCurrent?.username; + + if (accountId == null || userName == null) { + logError('SearchInputFormWidget::fetchRecentActionCallback: accountId or userName is null'); + return []; + } else { + return _searchController.getAllRecentSearchAction( + accountId, + userName, + pattern, + ); + } + }, + itemRecentBuilder: (context, recent) => RecentSearchItemTileWidget(recent), + onRecentSelected: _invokeSelectRecentItem, + suggestionsCallback: _dashBoardController.quickSearchEmails, + itemBuilder: (context, email) => EmailQuickSearchItemTileWidget( + email, + _dashBoardController.selectedMailbox.value, + searchQuery: SearchQuery(_searchController.currentSearchText.trim())), + onSuggestionSelected: _invokeSelectSuggestionItem, + contactItemBuilder: (context, emailAddress) => ContactQuickSearchItem(emailAddress: emailAddress), + contactSuggestionsCallback: _dashBoardController.getContactSuggestion, + onContactSuggestionSelected: _invokeSelectContactSuggestion, + ); + + if (_searchController.keyboardFocusNode != null) { + searchInputForm = KeyboardHandlerWrapper( + focusNode: _searchController.keyboardFocusNode!, + onKeyDownEventAction: _searchController.onKeyDownEventAction, + child: searchInputForm, + ); + } + return PortalTarget( visible: _searchController.isAdvancedSearchViewOpen.isTrue, portalFollower: PointerInterceptor( @@ -73,87 +165,7 @@ class SearchInputFormWidget extends StatelessWidget with AppLoaderMixin { ), ), portalFollower: const AdvancedSearchFilterOverlay(), - child: QuickSearchInputForm( - maxHeight: 52, - suggestionsBoxVerticalOffset: 0.0, - minInputLengthAutocomplete: _dashBoardController.minInputLengthAutocomplete, - textFieldConfiguration: _createConfiguration(context), - suggestionsBoxDecoration: const QuickSearchSuggestionsBoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.all(Radius.circular(16)), - ), - debounceDuration: const Duration(milliseconds: 300), - listActionButton: const [ - QuickSearchFilter.hasAttachment, - QuickSearchFilter.last7Days, - QuickSearchFilter.fromMe, - QuickSearchFilter.starred, - ], - actionButtonBuilder: (context, filterAction, suggestionsListState) { - if (filterAction is QuickSearchFilter) { - return buildListButtonForQuickSearchForm( - context, - filterAction, - suggestionsListState); - } else { - return const SizedBox.shrink(); - } - }, - buttonActionCallback: (filterAction) { - if (filterAction is QuickSearchFilter) { - _searchController.addQuickSearchFilterToSuggestionSearchView(filterAction); - } - }, - listActionPadding: const EdgeInsets.only(left: 12, right: 12, top: 12, bottom: 6), - titleHeaderRecent: Padding( - padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8, top: 12), - child: Text( - AppLocalizations.of(context).recent, - style: ThemeUtils.defaultTextStyleInterFont.copyWith( - fontSize: 13.0, - color: AppColor.colorTextButtonHeaderThread, - fontWeight: FontWeight.w500 - ) - ) - ), - buttonShowAllResult: (context, keyword, _) { - if (keyword is String) { - return _buildShowAllResultButton(context, keyword); - } else { - return const SizedBox.shrink(); - } - }, - loadingBuilder: (context) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: loadingWidget - ), - fetchRecentActionCallback: (pattern) { - final accountId = _dashBoardController.accountId.value; - final userName = _dashBoardController.sessionCurrent?.username; - - if (accountId == null || userName == null) { - logError('SearchInputFormWidget::fetchRecentActionCallback: accountId or userName is null'); - return []; - } else { - return _searchController.getAllRecentSearchAction( - accountId, - userName, - pattern, - ); - } - }, - itemRecentBuilder: (context, recent) => RecentSearchItemTileWidget(recent), - onRecentSelected: _invokeSelectRecentItem, - suggestionsCallback: _dashBoardController.quickSearchEmails, - itemBuilder: (context, email) => EmailQuickSearchItemTileWidget( - email, - _dashBoardController.selectedMailbox.value, - searchQuery: SearchQuery(_searchController.currentSearchText.trim())), - onSuggestionSelected: _invokeSelectSuggestionItem, - contactItemBuilder: (context, emailAddress) => ContactQuickSearchItem(emailAddress: emailAddress), - contactSuggestionsCallback: _dashBoardController.getContactSuggestion, - onContactSuggestionSelected: _invokeSelectContactSuggestion, - ) + child: searchInputForm, ), ); });