From 31549ebad3dd342ee0082c24f4733c4d53c206a9 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 4 Aug 2025 12:45:02 +0700 Subject: [PATCH] TF-3911 Support mail keyboard shortcut actions in email searched list view on web mobile --- ...e_keyboard_shortcut_actions_extension.dart | 71 +++++++ .../presentation/search_email_controller.dart | 27 ++- .../email/presentation/search_email_view.dart | 189 ++++++++++-------- 3 files changed, 194 insertions(+), 93 deletions(-) create mode 100644 lib/features/search/email/presentation/extension/handle_keyboard_shortcut_actions_extension.dart diff --git a/lib/features/search/email/presentation/extension/handle_keyboard_shortcut_actions_extension.dart b/lib/features/search/email/presentation/extension/handle_keyboard_shortcut_actions_extension.dart new file mode 100644 index 000000000..9d380fd14 --- /dev/null +++ b/lib/features/search/email/presentation/extension/handle_keyboard_shortcut_actions_extension.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/platform_info.dart'; +import 'package:flutter/material.dart'; +import 'package:model/extensions/list_presentation_email_extension.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:tmail_ui_user/features/base/shortcut/app_shortcut_manager.dart'; +import 'package:tmail_ui_user/features/base/shortcut/mail/mail_list_action_shortcut_type.dart'; +import 'package:tmail_ui_user/features/search/email/presentation/model/mail_searched_list_shortcut_action_view_event.dart'; +import 'package:tmail_ui_user/features/search/email/presentation/search_email_controller.dart'; +import 'package:tmail_ui_user/features/thread/presentation/model/mail_list_shortcut_action_view_event.dart'; + +extension HandleKeyboardShortcutActionsExtension on SearchEmailController { + void onKeyboardShortcutInit() { + if (PlatformInfo.isWeb) { + keyboardFocusNode = FocusNode(); + shortcutActionEventController = + StreamController(); + shortcutActionEventSubscription = shortcutActionEventController + ?.stream + .debounceTime(const Duration(milliseconds: 300)) + .listen(_onShortcutActionViewEvent); + } + } + + void _onShortcutActionViewEvent(MailListShortcutActionViewEvent event) { + handleSelectionEmailAction(event.actionType, event.listPresentationEmail); + } + + void onKeyboardShortcutDispose() { + if (PlatformInfo.isWeb) { + keyboardFocusNode?.dispose(); + shortcutActionEventSubscription?.cancel(); + shortcutActionEventController?.close(); + } + } + + void clearMailShortcutFocus() { + if (keyboardFocusNode?.hasFocus == true) { + keyboardFocusNode?.unfocus(); + } + } + + void refocusMailShortcutFocus() { + if (keyboardFocusNode?.hasFocus == false) { + keyboardFocusNode?.requestFocus(); + } + } + + void onKeyDownEventAction(KeyEvent event) { + final shortcutType = AppShortcutManager.getMailListActionFromEvent(event); + log('$runtimeType::onKeyDownEventAction:🔥Shortcut triggered: $shortcutType'); + if (shortcutType == null) return; + handleMailSearchListShortcutAction(shortcutType); + } + + void handleMailSearchListShortcutAction(MailListActionShortcutType shortcutType) { + log('$runtimeType::handleMailSearchListShortcutAction: 🔥 Shortcut triggered: $shortcutType'); + final selectedEmails = listResultSearch.listEmailSelected; + final emailActionType = shortcutType.getEmailActionTypeBySearch( + selectedEmails: selectedEmails, + ); + + if (emailActionType == null) return; + + shortcutActionEventController?.add( + MailSearchedListShortcutActionViewEvent(emailActionType, selectedEmails), + ); + } +} \ No newline at end of file diff --git a/lib/features/search/email/presentation/search_email_controller.dart b/lib/features/search/email/presentation/search_email_controller.dart index 09826f5a0..6d1e0dc10 100644 --- a/lib/features/search/email/presentation/search_email_controller.dart +++ b/lib/features/search/email/presentation/search_email_controller.dart @@ -1,8 +1,9 @@ +import 'dart:async'; + import 'package:core/presentation/extensions/either_view_state_extension.dart'; import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/state/success.dart'; -import 'package:core/presentation/utils/keyboard_utils.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/platform_info.dart'; import 'package:dartz/dartz.dart'; @@ -59,6 +60,7 @@ import 'package:tmail_ui_user/features/push_notification/presentation/websocket/ import 'package:tmail_ui_user/features/push_notification/presentation/websocket/web_socket_queue_handler.dart'; import 'package:tmail_ui_user/features/search/email/domain/state/refresh_changes_search_email_state.dart'; import 'package:tmail_ui_user/features/search/email/domain/usecases/refresh_changes_search_email_interactor.dart'; +import 'package:tmail_ui_user/features/search/email/presentation/extension/handle_keyboard_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/search/email/presentation/model/search_more_state.dart'; import 'package:tmail_ui_user/features/search/email/presentation/search_email_bindings.dart'; import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.dart'; @@ -71,6 +73,7 @@ import 'package:tmail_ui_user/features/thread/domain/usecases/search_more_email_ import 'package:tmail_ui_user/features/thread/presentation/extensions/list_presentation_email_extensions.dart'; import 'package:tmail_ui_user/features/thread/presentation/mixin/email_action_controller.dart'; import 'package:tmail_ui_user/features/thread/presentation/model/delete_action_type.dart'; +import 'package:tmail_ui_user/features/thread/presentation/model/mail_list_shortcut_action_view_event.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/dialog_router.dart'; @@ -114,6 +117,9 @@ class SearchEmailController extends BaseController late SearchMoreState searchMoreState; late bool canSearchMore; WebSocketQueueHandler? _webSocketQueueHandler; + FocusNode? keyboardFocusNode; + StreamController? shortcutActionEventController; + StreamSubscription? shortcutActionEventSubscription; PresentationMailbox? get currentMailbox => mailboxDashBoardController.selectedMailbox.value; @@ -157,6 +163,7 @@ class SearchEmailController extends BaseController _initializeTextInputFocus(); _initWorkerListener(); _initWebSocketQueueHandler(); + onKeyboardShortcutInit(); } @override @@ -446,7 +453,6 @@ class SearchEmailController extends BaseController } void _searchEmailAction(BuildContext context) { - KeyboardUtils.hideKeyboard(context); textInputSearchFocus.unfocus(); if (session == null || accountId == null) { @@ -513,6 +519,10 @@ class SearchEmailController extends BaseController duration: const Duration(milliseconds: 500), curve: Curves.fastOutSlowIn); } + + if (PlatformInfo.isWeb) { + refocusMailShortcutFocus(); + } } void _searchEmailsFailure(SearchEmailFailure failure) { @@ -825,6 +835,8 @@ class SearchEmailController extends BaseController listContactSuggestionSearch.clear(); if (requestFocus) { textInputSearchFocus.requestFocus(); + } else { + textInputSearchFocus.unfocus(); } } @@ -843,9 +855,6 @@ class SearchEmailController extends BaseController void closeSearchView({BuildContext? context}) { clearAllTextInputSearchForm(); clearAllResultSearch(); - if (context != null) { - KeyboardUtils.hideKeyboard(context); - } mailboxDashBoardController.searchController.disableAllSearchEmail(); mailboxDashBoardController.dispatchRoute(DashboardRoutes.thread); if (PlatformInfo.isWeb) { @@ -927,6 +936,10 @@ class SearchEmailController extends BaseController selectionMode.value = SelectMode.ACTIVE; } } + + if (PlatformInfo.isWeb) { + refocusMailShortcutFocus(); + } } void cancelSelectionMode() { @@ -934,6 +947,9 @@ class SearchEmailController extends BaseController .map((email) => email.toSelectedEmail(selectMode: SelectMode.INACTIVE)) .toList(); selectionMode.value = SelectMode.INACTIVE; + if (PlatformInfo.isWeb) { + clearMailShortcutFocus(); + } } void setSelectAllEmailAction() { @@ -1107,6 +1123,7 @@ class SearchEmailController extends BaseController emailUIActionWorker.dispose(); dashBoardActionWorker.dispose(); _webSocketQueueHandler?.dispose(); + onKeyboardShortcutDispose(); super.onClose(); } } \ No newline at end of file diff --git a/lib/features/search/email/presentation/search_email_view.dart b/lib/features/search/email/presentation/search_email_view.dart index 3bbbdeff1..27517382b 100644 --- a/lib/features/search/email/presentation/search_email_view.dart +++ b/lib/features/search/email/presentation/search_email_view.dart @@ -14,6 +14,7 @@ import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:model/model.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/base/widget/popup_menu/popup_menu_item_action_widget.dart'; import 'package:tmail_ui_user/features/base/widget/scrollbar_list_view.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/model/recent_search.dart'; @@ -25,6 +26,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/qu import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/quick_search/recent_search_item_tile_widget.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/search_filters/search_filter_button.dart'; import 'package:tmail_ui_user/features/search/email/presentation/extension/handle_email_more_action_extension.dart'; +import 'package:tmail_ui_user/features/search/email/presentation/extension/handle_keyboard_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/search/email/presentation/extension/handle_press_email_selection_action.dart'; import 'package:tmail_ui_user/features/search/email/presentation/model/context_item_receive_time_type_action.dart'; import 'package:tmail_ui_user/features/search/email/presentation/model/context_item_sort_order_type_action.dart'; @@ -59,101 +61,112 @@ class SearchEmailView extends GetWidget }); } - return Scaffold( - body: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), + final bodyWidget = Column(children: [ + PopScope( + canPop: false, + onPopInvokedWithResult: (_, __) { + if (!PlatformInfo.isAndroid) return; + controller.closeSearchView(context: context); + }, child: Container( - color: Colors.white, - child: Column(children: [ - PopScope( - canPop: false, - onPopInvokedWithResult: (_, __) { - if (!PlatformInfo.isAndroid) return; - controller.closeSearchView(context: context); - }, - child: Container( - height: 52, - color: Colors.white, - padding: SearchEmailViewStyle.getAppBarPadding( - context, - controller.responsiveUtils - ), - child: Obx(() { - if (controller.selectionMode.value == SelectMode.ACTIVE) { - final mapMailboxById = controller.mailboxDashBoardController.mapMailboxById; - final selectedEmails = controller.listResultSearch.listEmailSelected; - - final actionTypes = _createEmailSelectionActionTypes( - mapMailboxById, - selectedEmails, - ); - - return SelectionMobileAppBarThreadWidget( - imagePaths: controller.imagePaths, - responsiveUtils: controller.responsiveUtils, - selectedEmails: selectedEmails, - padding: EdgeInsets.zero, - onCancelSelectionAction: controller.cancelSelectionMode, - emailSelectionActionTypes: actionTypes, - onPressEmailSelectionActionClick: (type, emails) => - controller.handlePressEmailSelectionAction( - context, - type, - emails, - mapMailboxById, - ), - ); - } else { - return _buildSearchInputForm(context); - } - }) - ), + height: 52, + color: Colors.white, + padding: SearchEmailViewStyle.getAppBarPadding( + context, + controller.responsiveUtils ), - const Divider(color: AppColor.colorDividerComposer, height: 1), - _buildListSearchFilterAction(context), - Obx(() => SearchEmailLoadingBarWidget( - suggestionViewState: controller.suggestionSearchViewState.value, - resultSearchViewState: controller.resultSearchViewState.value, - )), - Expanded(child: Obx(() { - if (controller.searchIsRunning.isFalse) { - return SingleChildScrollView( - physics: const ClampingScrollPhysics(), - child: Column(children: [ - if (controller.currentSearchText.value.isNotEmpty) - _buildShowAllResultSearchButton(context, controller.currentSearchText.value), - if (controller.listContactSuggestionSearch.isNotEmpty - && controller.currentSearchText.isNotEmpty) - _buildListContactSuggestionSearch(context, controller.listContactSuggestionSearch), - if (controller.listContactSuggestionSearch.isNotEmpty - && controller.listSuggestionSearch.isNotEmpty) - const Divider(), - if (controller.listSuggestionSearch.isNotEmpty && controller.currentSearchText.isNotEmpty) - _buildListSuggestionSearch(context, controller.listSuggestionSearch) - else if (controller.listRecentSearch.isNotEmpty && controller.listSuggestionSearch.isEmpty) - _buildListRecentSearch(context, controller.listRecentSearch) - ]), + child: Obx(() { + if (controller.selectionMode.value == SelectMode.ACTIVE) { + final mapMailboxById = controller.mailboxDashBoardController.mapMailboxById; + final selectedEmails = controller.listResultSearch.listEmailSelected; + + final actionTypes = _createEmailSelectionActionTypes( + mapMailboxById, + selectedEmails, + ); + + return SelectionMobileAppBarThreadWidget( + imagePaths: controller.imagePaths, + responsiveUtils: controller.responsiveUtils, + selectedEmails: selectedEmails, + padding: EdgeInsets.zero, + onCancelSelectionAction: controller.cancelSelectionMode, + emailSelectionActionTypes: actionTypes, + onPressEmailSelectionActionClick: (type, emails) => + controller.handlePressEmailSelectionAction( + context, + type, + emails, + mapMailboxById, + ), ); } else { - if (controller.listResultSearch.isNotEmpty) { - return _buildListEmailBody( - context, - controller.listResultSearch - ); - } else { - return Obx(() => EmptySearchEmailWidget( - suggestionViewState: controller.suggestionSearchViewState.value, - resultSearchViewState: controller.resultSearchViewState.value, - isNetworkConnectionAvailable: controller.networkConnectionController.isNetworkConnectionAvailable(), - )); - } + return _buildSearchInputForm(context); } - })), - _buildLoadingViewLoadMore(), - ]), + }) ), ), - ); + const Divider(color: AppColor.colorDividerComposer, height: 1), + _buildListSearchFilterAction(context), + Obx(() => SearchEmailLoadingBarWidget( + suggestionViewState: controller.suggestionSearchViewState.value, + resultSearchViewState: controller.resultSearchViewState.value, + )), + Expanded(child: Obx(() { + if (controller.searchIsRunning.isFalse) { + return SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Column(children: [ + if (controller.currentSearchText.value.isNotEmpty) + _buildShowAllResultSearchButton(context, controller.currentSearchText.value), + if (controller.listContactSuggestionSearch.isNotEmpty + && controller.currentSearchText.isNotEmpty) + _buildListContactSuggestionSearch(context, controller.listContactSuggestionSearch), + if (controller.listContactSuggestionSearch.isNotEmpty + && controller.listSuggestionSearch.isNotEmpty) + const Divider(), + if (controller.listSuggestionSearch.isNotEmpty && controller.currentSearchText.isNotEmpty) + _buildListSuggestionSearch(context, controller.listSuggestionSearch) + else if (controller.listRecentSearch.isNotEmpty && controller.listSuggestionSearch.isEmpty) + _buildListRecentSearch(context, controller.listRecentSearch) + ]), + ); + } else { + if (controller.listResultSearch.isNotEmpty) { + return _buildListEmailBody( + context, + controller.listResultSearch + ); + } else { + return Obx(() => EmptySearchEmailWidget( + suggestionViewState: controller.suggestionSearchViewState.value, + resultSearchViewState: controller.resultSearchViewState.value, + isNetworkConnectionAvailable: controller.networkConnectionController.isNetworkConnectionAvailable(), + )); + } + } + })), + _buildLoadingViewLoadMore(), + ]); + + if (PlatformInfo.isWeb && controller.keyboardFocusNode != null) { + return KeyboardHandlerWrapper( + onKeyDownEventAction: controller.onKeyDownEventAction, + focusNode: controller.keyboardFocusNode!, + child: Scaffold( + backgroundColor: Colors.white, + body: bodyWidget, + ), + ); + } else { + return Scaffold( + backgroundColor: Colors.white, + body: GestureDetector( + onTap: controller.textInputSearchFocus.unfocus, + child: bodyWidget, + ), + ); + } } List _createEmailSelectionActionTypes(