From 226f6520a77b4b941896ed32889035b5971f04a6 Mon Sep 17 00:00:00 2001 From: dab246 Date: Tue, 29 Jul 2025 03:25:26 +0700 Subject: [PATCH] TF-3911 Support mail keyboard shortcut actions in email list view on web --- .../base/shortcut/app_shortcut_manager.dart | 18 + .../mail/mail_list_action_shortcut_type.dart | 40 ++ .../controller/single_email_controller.dart | 6 +- .../presentation/action/dashboard_action.dart | 4 +- .../bindings/mailbox_dashboard_bindings.dart | 4 +- .../mailbox_dashboard_controller.dart | 3 + ...andle_reactive_obx_variable_extension.dart | 17 +- .../search_mailbox_controller.dart | 25 + .../presentation/search_mailbox_view.dart | 21 +- ...e_keyboard_shortcut_actions_extension.dart | 76 +++ ...andle_shift_selection_email_extension.dart | 23 + .../mail_list_shortcut_action_view_event.dart | 13 + .../presentation/thread_controller.dart | 81 ++-- .../thread/presentation/thread_view.dart | 447 +++++++++--------- ...andle_mail_shortcut_actions_extension.dart | 3 - 15 files changed, 515 insertions(+), 266 deletions(-) create mode 100644 lib/features/base/shortcut/mail/mail_list_action_shortcut_type.dart create mode 100644 lib/features/thread/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart create mode 100644 lib/features/thread/presentation/extensions/handle_shift_selection_email_extension.dart create mode 100644 lib/features/thread/presentation/model/mail_list_shortcut_action_view_event.dart diff --git a/lib/features/base/shortcut/app_shortcut_manager.dart b/lib/features/base/shortcut/app_shortcut_manager.dart index 059e01892..9762f5e56 100644 --- a/lib/features/base/shortcut/app_shortcut_manager.dart +++ b/lib/features/base/shortcut/app_shortcut_manager.dart @@ -1,6 +1,7 @@ import 'package:core/utils/app_logger.dart'; 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'; class AppShortcutManager { @@ -24,4 +25,21 @@ class AppShortcutManager { return null; } } + + static MailListActionShortcutType? getMailListActionFromEvent(KeyEvent event) { + final keysPressed = HardwareKeyboard.instance.logicalKeysPressed; + log('AppShortcutManager::getMailListActionFromEvent: Keys pressed: $keysPressed'); + if (keysPressed.isOnly(LogicalKeyboardKey.delete) || + keysPressed.isOnly(LogicalKeyboardKey.backspace)) { + return MailListActionShortcutType.delete; + } else if (keysPressed.isOnly(LogicalKeyboardKey.keyN)) { + return MailListActionShortcutType.newMessage; + } else if (keysPressed.isOnly(LogicalKeyboardKey.keyQ)) { + return MailListActionShortcutType.markAsRead; + } else if (keysPressed.isOnly(LogicalKeyboardKey.keyU)) { + return MailListActionShortcutType.markAsUnread; + } else { + return null; + } + } } diff --git a/lib/features/base/shortcut/mail/mail_list_action_shortcut_type.dart b/lib/features/base/shortcut/mail/mail_list_action_shortcut_type.dart new file mode 100644 index 000000000..0a9c1f003 --- /dev/null +++ b/lib/features/base/shortcut/mail/mail_list_action_shortcut_type.dart @@ -0,0 +1,40 @@ + +import 'package:model/email/email_action_type.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:model/extensions/list_presentation_email_extension.dart'; +import 'package:model/extensions/presentation_mailbox_extension.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; + +enum MailListActionShortcutType { + delete, + newMessage, + markAsRead, + markAsUnread; + + EmailActionType? getEmailActionType({ + required List selectedEmails, + PresentationMailbox? selectedMailbox, + }) { + if (selectedEmails.isEmpty && + this != MailListActionShortcutType.newMessage) { + return null; + } + + switch(this) { + case MailListActionShortcutType.delete: + return selectedMailbox?.isDeletePermanentlyEnabled == true + ? EmailActionType.deletePermanently + : EmailActionType.moveToTrash; + case MailListActionShortcutType.newMessage: + return EmailActionType.compose; + case MailListActionShortcutType.markAsRead: + return selectedEmails.isAllEmailRead + ? null + : EmailActionType.markAsRead; + case MailListActionShortcutType.markAsUnread: + return selectedEmails.isAllEmailUnread + ? null + : EmailActionType.markAsUnread; + } + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 7c821189d..12569227e 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -443,11 +443,15 @@ class SingleEmailController extends BaseController with AppLoaderMixin { ); mailboxDashBoardController.clearEmailUIAction(); } else if (action is TriggerMailViewKeyboardShortcutAction) { + mailboxDashBoardController.clearEmailUIAction(); + if (_currentEmailId == null || + action.email.id != _currentEmailId) { + return; + } handleMailActionByShortcutAction( actionType: action.actionType, email: action.email, ); - mailboxDashBoardController.clearEmailUIAction(); } })); diff --git a/lib/features/mailbox_dashboard/presentation/action/dashboard_action.dart b/lib/features/mailbox_dashboard/presentation/action/dashboard_action.dart index 657489b68..a140e95d2 100644 --- a/lib/features/mailbox_dashboard/presentation/action/dashboard_action.dart +++ b/lib/features/mailbox_dashboard/presentation/action/dashboard_action.dart @@ -157,4 +157,6 @@ class SynchronizeEmailSortOrderAction extends DashBoardAction { List get props => [emailSortOrderType]; } -class ReclaimMailListKeyboardShortcutFocusAction extends DashBoardAction {} \ No newline at end of file +class ReclaimMailListKeyboardShortcutFocusAction extends DashBoardAction {} + +class ClearMailListKeyboardShortcutFocusAction extends DashBoardAction {} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart b/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart index 9a104d90d..3a6f8c8d2 100644 --- a/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart +++ b/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart @@ -37,7 +37,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_ import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/restore_deleted_message_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/unsubscribe_email_interactor.dart'; -import 'package:tmail_ui_user/features/email/presentation/bindings/email_interactor_bindings.dart'; +import 'package:tmail_ui_user/features/email/presentation/bindings/email_bindings.dart'; import 'package:tmail_ui_user/features/home/domain/repository/session_repository.dart'; import 'package:tmail_ui_user/features/home/domain/usecases/store_session_interactor.dart'; import 'package:tmail_ui_user/features/identity_creator/data/datasource/identity_creator_data_source.dart'; @@ -155,7 +155,7 @@ class MailboxDashBoardBindings extends BaseBindings { SendingQueueBindings().dependencies(); MailboxBindings().dependencies(); ThreadBindings().dependencies(); - EmailInteractorBindings().dependencies(); + EmailBindings().dependencies(); SearchEmailBindings().dependencies(); QuotasBindings().dependencies(); ThreadDetailBindings().dependencies(); diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index 5701b18c5..bd8840abe 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -3286,6 +3286,9 @@ class MailboxDashBoardController extends ReloadableController bool get isEmailOpened => dashboardRoute.value == DashboardRoutes.threadDetailed; + bool get isEmailListDisplayed => + dashboardRoute.value == DashboardRoutes.thread; + @override void onClose() { if (PlatformInfo.isWeb) { diff --git a/lib/features/mailbox_dashboard/presentation/extensions/handle_reactive_obx_variable_extension.dart b/lib/features/mailbox_dashboard/presentation/extensions/handle_reactive_obx_variable_extension.dart index 407aa85b0..e9275b872 100644 --- a/lib/features/mailbox_dashboard/presentation/extensions/handle_reactive_obx_variable_extension.dart +++ b/lib/features/mailbox_dashboard/presentation/extensions/handle_reactive_obx_variable_extension.dart @@ -1,6 +1,7 @@ import 'package:core/utils/app_logger.dart'; import 'package:get/get_rx/src/rx_workers/rx_workers.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/thread_detail/presentation/action/thread_detail_ui_action.dart'; @@ -14,7 +15,7 @@ extension HandleReactiveObxVariableExtension on MailboxDashBoardController { searchInputFocusWorker = ever( searchController.isSearchInputFocused, - _onSearchInputFocusChanged + onSearchInputFocusChanged ); } @@ -22,13 +23,17 @@ extension HandleReactiveObxVariableExtension on MailboxDashBoardController { log('$runtimeType::_onAdvancedSearchVisibleChanged: visible is $visible | isEmailOpened = $isEmailOpened'); if (isEmailOpened) { _performThreadDetailUIActionWhenEmailOpened(visible); + } else if (isEmailListDisplayed) { + _performDashboardUIActionWhenEmailListDisplayed(visible); } } - void _onSearchInputFocusChanged(bool focused) { + void onSearchInputFocusChanged(bool focused) { log('$runtimeType::_onSearchInputFocusChanged: focused is $focused | isEmailOpened = $isEmailOpened'); if (isEmailOpened) { _performThreadDetailUIActionWhenEmailOpened(focused); + } else if (isEmailListDisplayed) { + _performDashboardUIActionWhenEmailListDisplayed(focused); } } @@ -40,6 +45,14 @@ extension HandleReactiveObxVariableExtension on MailboxDashBoardController { dispatchThreadDetailUIAction(emailAction); } + void _performDashboardUIActionWhenEmailListDisplayed(bool focused) { + final dashboardAction = focused + ? ClearMailListKeyboardShortcutFocusAction() + : ReclaimMailListKeyboardShortcutFocusAction(); + + dispatchAction(dashboardAction); + } + void disposeReactiveObxVariableListener() { advancedSearchVisibleWorker?.dispose(); advancedSearchVisibleWorker = null; diff --git a/lib/features/search/mailbox/presentation/search_mailbox_controller.dart b/lib/features/search/mailbox/presentation/search_mailbox_controller.dart index bd4ba8233..148f71773 100644 --- a/lib/features/search/mailbox/presentation/search_mailbox_controller.dart +++ b/lib/features/search/mailbox/presentation/search_mailbox_controller.dart @@ -65,6 +65,7 @@ import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/mailbo import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/new_mailbox_arguments.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_create_new_rule_filter.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_reactive_obx_variable_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dashboard_routes.dart'; import 'package:tmail_ui_user/features/search/mailbox/presentation/search_mailbox_bindings.dart'; import 'package:tmail_ui_user/features/thread/domain/model/search_query.dart'; @@ -90,6 +91,7 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa final listMailboxSearched = RxList(); final textInputSearchController = TextEditingController(); late Debouncer _deBouncerTime; + FocusNode? searchFocusNode; PresentationMailbox? get selectedMailbox => dashboardController.selectedMailbox.value; @@ -124,6 +126,9 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa super.onInit(); _initializeDebounceTimeTextSearchChange(); _registerObxStreamListener(); + if (PlatformInfo.isWeb) { + _registerInputFocusListener(); + } _getAllMailboxAction(); } @@ -215,6 +220,23 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa }); } + void _registerInputFocusListener() { + searchFocusNode = FocusNode(); + searchFocusNode?.addListener(_onSearchFocusChanged); + searchFocusNode?.requestFocus(); + } + + void _onSearchFocusChanged() { + log('$runtimeType::_onSearchFocusChanged: ${searchFocusNode?.hasFocus}'); + final hasFocus = searchFocusNode?.hasFocus ?? false; + dashboardController.onSearchInputFocusChanged(hasFocus); + } + + void _disposeInputFocusListener() { + searchFocusNode?.removeListener(_onSearchFocusChanged); + searchFocusNode?.dispose(); + } + void _getAllMailboxAction() { if (session != null && accountId != null) { getAllMailbox(session!, accountId!); @@ -837,6 +859,9 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa void onClose() { textInputSearchController.dispose(); _deBouncerTime.cancel(); + if (PlatformInfo.isWeb) { + _disposeInputFocusListener(); + } super.onClose(); } } \ No newline at end of file diff --git a/lib/features/search/mailbox/presentation/search_mailbox_view.dart b/lib/features/search/mailbox/presentation/search_mailbox_view.dart index 88dff57ca..1811aef7f 100644 --- a/lib/features/search/mailbox/presentation/search_mailbox_view.dart +++ b/lib/features/search/mailbox/presentation/search_mailbox_view.dart @@ -38,14 +38,18 @@ class SearchMailboxView extends GetWidget @override Widget build(BuildContext context) { - return Scaffold( - body: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: PlatformInfo.isWeb - ? PointerInterceptor(child: _buildSearchBody(context)) - : SafeArea(child: _buildSearchBody(context)), - ), - ); + if (PlatformInfo.isWeb) { + return Scaffold( + body: PointerInterceptor(child: _buildSearchBody(context)), + ); + } else { + return Scaffold( + body: GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: SafeArea(child: _buildSearchBody(context)), + ), + ); + } } Widget _buildSearchBody(BuildContext context) { @@ -150,6 +154,7 @@ class SearchMailboxView extends GetWidget autoFocus: true, maxLines: 1, controller: controller.textInputSearchController, + focusNode: controller.searchFocusNode, textDirection: DirectionUtils.getDirectionByLanguage(context), textStyle: ThemeUtils.textStyleBodyBody2( color: Colors.black, diff --git a/lib/features/thread/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart b/lib/features/thread/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart new file mode 100644 index 000000000..7504a59e5 --- /dev/null +++ b/lib/features/thread/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/platform_info.dart'; +import 'package:flutter/material.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/thread/presentation/extensions/handle_shift_selection_email_extension.dart'; +import 'package:tmail_ui_user/features/thread/presentation/model/mail_list_shortcut_action_view_event.dart'; +import 'package:tmail_ui_user/features/thread/presentation/thread_controller.dart'; + +extension HandleKeyboardShortcutActionsExtension on ThreadController { + void onKeyboardShortcutInit() { + if (PlatformInfo.isWeb) { + keyboardFocusNode = FocusNode(); + shortcutActionEventController = + StreamController(); + shortcutActionEventSubscription = shortcutActionEventController + ?.stream + .debounceTime(const Duration(milliseconds: 300)) + .listen(_onShortcutActionViewEvent); + } + } + + void _onShortcutActionViewEvent(MailListShortcutActionViewEvent event) { + pressEmailSelectionAction(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) { + handleShiftSelectionEmail(event); + + final shortcutType = AppShortcutManager.getMailListActionFromEvent(event); + log('$runtimeType::onKeyDownEventAction:🔥Shortcut triggered: $shortcutType'); + if (shortcutType == null) return; + handleMailListShortcutAction(shortcutType); + } + + void handleMailListShortcutAction(MailListActionShortcutType shortcutType) { + log('$runtimeType::handleMailListShortcutAction: 🔥 Shortcut triggered: $shortcutType'); + final currentMailbox = selectedMailbox; + if (currentMailbox == null) return; + + final selectedEmails = listEmailSelected; + final emailActionType = shortcutType.getEmailActionType( + selectedEmails: selectedEmails, + selectedMailbox: currentMailbox, + ); + + if (emailActionType == null) return; + + shortcutActionEventController?.add( + MailListShortcutActionViewEvent(emailActionType, selectedEmails), + ); + } +} \ No newline at end of file diff --git a/lib/features/thread/presentation/extensions/handle_shift_selection_email_extension.dart b/lib/features/thread/presentation/extensions/handle_shift_selection_email_extension.dart new file mode 100644 index 000000000..0d9f4e9a0 --- /dev/null +++ b/lib/features/thread/presentation/extensions/handle_shift_selection_email_extension.dart @@ -0,0 +1,23 @@ + +import 'package:core/utils/app_logger.dart'; +import 'package:flutter/services.dart'; +import 'package:tmail_ui_user/features/base/extensions/logical_key_set_helper.dart'; +import 'package:tmail_ui_user/features/thread/presentation/thread_controller.dart'; + +extension HandleShiftSelectionEmailExtension on ThreadController { + void onKeyUpEventAction(KeyEvent event) { + handleShiftSelectionEmail(event); + } + + void handleShiftSelectionEmail(KeyEvent event) { + final keysPressed = HardwareKeyboard.instance.logicalKeysPressed; + final shiftPressed = keysPressed.isShift(); + log('$runtimeType::handleShiftSelectionEmail:Event = ${event.runtimeType} keysPressed = $keysPressed | shiftPressed = $shiftPressed'); + if (event is KeyDownEvent && shiftPressed) { + rangeSelectionMode = true; + } + if (event is KeyUpEvent && !shiftPressed) { + rangeSelectionMode = false; + } + } +} \ No newline at end of file diff --git a/lib/features/thread/presentation/model/mail_list_shortcut_action_view_event.dart b/lib/features/thread/presentation/model/mail_list_shortcut_action_view_event.dart new file mode 100644 index 000000000..da6cdf118 --- /dev/null +++ b/lib/features/thread/presentation/model/mail_list_shortcut_action_view_event.dart @@ -0,0 +1,13 @@ +import 'package:core/presentation/state/success.dart'; +import 'package:model/email/email_action_type.dart'; +import 'package:model/email/presentation_email.dart'; + +class MailListShortcutActionViewEvent extends ViewEvent { + final EmailActionType actionType; + final List listPresentationEmail; + + MailListShortcutActionViewEvent(this.actionType, this.listPresentationEmail); + + @override + List get props => [actionType, listPresentationEmail]; +} \ No newline at end of file diff --git a/lib/features/thread/presentation/thread_controller.dart b/lib/features/thread/presentation/thread_controller.dart index 6798ca1eb..5f5581f89 100644 --- a/lib/features/thread/presentation/thread_controller.dart +++ b/lib/features/thread/presentation/thread_controller.dart @@ -7,7 +7,6 @@ import 'package:core/utils/app_logger.dart'; import 'package:core/utils/platform_info.dart'; import 'package:dartz/dartz.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; @@ -24,12 +23,14 @@ import 'package:tmail_ui_user/features/email/domain/state/delete_email_permanent import 'package:tmail_ui_user/features/email/domain/state/delete_multiple_emails_permanently_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/move_to_mailbox_state.dart'; import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/mark_as_mailbox_read_state.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/dashboard_action.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/move_emails_to_mailbox_extension.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/open_and_close_composer_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/update_current_emails_flags_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dashboard_routes.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart'; @@ -60,11 +61,13 @@ import 'package:tmail_ui_user/features/thread/domain/usecases/load_more_emails_i import 'package:tmail_ui_user/features/thread/domain/usecases/refresh_changes_emails_in_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/thread/domain/usecases/search_email_interactor.dart'; import 'package:tmail_ui_user/features/thread/domain/usecases/search_more_email_interactor.dart'; +import 'package:tmail_ui_user/features/thread/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/thread/presentation/extensions/list_presentation_email_extensions.dart'; import 'package:tmail_ui_user/features/thread/presentation/extensions/refresh_thread_detail_extension.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/loading_more_status.dart'; +import 'package:tmail_ui_user/features/thread/presentation/model/mail_list_shortcut_action_view_event.dart'; import 'package:tmail_ui_user/features/thread/presentation/model/search_status.dart'; import 'package:tmail_ui_user/main/exceptions/remote_exception.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; @@ -89,7 +92,7 @@ class ThreadController extends BaseController with EmailActionController { final CleanAndGetEmailsInMailboxInteractor cleanAndGetEmailsInMailboxInteractor; final listEmailDrag = [].obs; - bool _rangeSelectionMode = false; + bool rangeSelectionMode = false; final openingEmail = RxBool(false); final loadingMoreStatus = Rx(LoadingMoreStatus.idle); @@ -101,7 +104,9 @@ class ThreadController extends BaseController with EmailActionController { @visibleForTesting bool isListEmailScrollViewJumping = false; WebSocketQueueHandler? _webSocketQueueHandler; - FocusNode? focusNodeKeyBoard; + FocusNode? keyboardFocusNode; + StreamController? shortcutActionEventController; + StreamSubscription? shortcutActionEventSubscription; StreamSubscription? _resizeBrowserStreamSubscription; @@ -133,8 +138,8 @@ class ThreadController extends BaseController with EmailActionController { void onInit() { _registerObxStreamListener(); if (PlatformInfo.isWeb) { - focusNodeKeyBoard = FocusNode(); _registerBrowserResizeListener(); + onKeyboardShortcutInit(); } _initWebSocketQueueHandler(); super.onInit(); @@ -150,10 +155,9 @@ class ThreadController extends BaseController with EmailActionController { void onClose() { _currentMemoryMailboxId = null; listEmailController.dispose(); - focusNodeKeyBoard?.dispose(); - focusNodeKeyBoard = null; if (PlatformInfo.isWeb) { _resizeBrowserStreamSubscription?.cancel(); + onKeyboardShortcutDispose(); } _webSocketQueueHandler?.dispose(); super.onClose(); @@ -231,11 +235,19 @@ class ThreadController extends BaseController with EmailActionController { @override void onDone() { - viewState.value.map((success) { - if (success is GetAllEmailSuccess) { - _handleOnDoneGetAllEmailSuccess(success); - } - }); + viewState.value.fold( + (failure) { + if (failure is GetAllEmailFailure || + failure is CleanAndGetAllEmailFailure) { + _handleOnDoneGetAllEmailFailure(); + } + }, + (success) { + if (success is GetAllEmailSuccess) { + _handleOnDoneGetAllEmailSuccess(); + } + }, + ); } void _initWebSocketQueueHandler() { @@ -328,6 +340,12 @@ class ThreadController extends BaseController with EmailActionController { } canSearchMore = true; mailboxDashBoardController.emailsInCurrentMailbox.clear(); + } else if (action is ReclaimMailListKeyboardShortcutFocusAction) { + refocusMailShortcutFocus(); + mailboxDashBoardController.clearDashBoardAction(); + } else if (action is ClearMailListKeyboardShortcutFocusAction) { + clearMailShortcutFocus(); + mailboxDashBoardController.clearDashBoardAction(); } }); @@ -508,9 +526,19 @@ class ThreadController extends BaseController with EmailActionController { } } - void _handleOnDoneGetAllEmailSuccess(GetAllEmailSuccess success) { + void _handleOnDoneGetAllEmailSuccess() { if (PlatformInfo.isWeb) { _validateBrowserHeight(); + + if (mailboxDashBoardController.isEmailListDisplayed) { + refocusMailShortcutFocus(); + } + } + } + + void _handleOnDoneGetAllEmailFailure() { + if (PlatformInfo.isWeb && mailboxDashBoardController.isEmailListDisplayed) { + refocusMailShortcutFocus(); } } @@ -862,7 +890,7 @@ class ThreadController extends BaseController with EmailActionController { void selectEmail(PresentationEmail presentationEmailSelected) { final emailsInCurrentMailbox = mailboxDashBoardController.emailsInCurrentMailbox; - if (_rangeSelectionMode && latestEmailSelectedOrUnselected.value != null && latestEmailSelectedOrUnselected.value?.id != presentationEmailSelected.id) { + if (rangeSelectionMode && latestEmailSelectedOrUnselected.value != null && latestEmailSelectedOrUnselected.value?.id != presentationEmailSelected.id) { _rangeSelectionEmailsAction(presentationEmailSelected); } else { final newEmailList = emailsInCurrentMailbox @@ -875,7 +903,7 @@ class ThreadController extends BaseController with EmailActionController { .firstWhereOrNull((e) => e.id == presentationEmailSelected.id); if (PlatformInfo.isWeb) { - focusNodeKeyBoard?.requestFocus(); + refocusMailShortcutFocus(); } if (mailboxDashBoardController.emailsInCurrentMailbox.isAllSelectionInActive) { @@ -896,6 +924,9 @@ class ThreadController extends BaseController with EmailActionController { mailboxDashBoardController.updateEmailList(newEmailList); mailboxDashBoardController.currentSelectMode.value = SelectMode.ACTIVE; mailboxDashBoardController.listEmailSelected.value = listEmailSelected; + if (PlatformInfo.isWeb) { + refocusMailShortcutFocus(); + } } List get listEmailSelected => @@ -1140,6 +1171,9 @@ class ThreadController extends BaseController with EmailActionController { cancelSelectEmail(); moveEmailsToArchive(selectionEmail); break; + case EmailActionType.compose: + mailboxDashBoardController.openComposer(ComposerArguments()); + break; default: break; } @@ -1233,20 +1267,6 @@ class ThreadController extends BaseController with EmailActionController { } } - KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { - final shiftEvent = event.logicalKey == LogicalKeyboardKey.shiftLeft || event.logicalKey == LogicalKeyboardKey.shiftRight; - if (event is KeyDownEvent && shiftEvent) { - _rangeSelectionMode = true; - } - - if (event is KeyUpEvent && shiftEvent) { - _rangeSelectionMode = false; - } - return shiftEvent - ? KeyEventResult.handled - : KeyEventResult.ignored; - } - PresentationEmail generateEmailByPlatform(PresentationEmail currentEmail) { if (PlatformInfo.isWeb) { final route = RouteUtils.createUrlWebLocationBar( @@ -1369,7 +1389,10 @@ class ThreadController extends BaseController with EmailActionController { mailboxDashBoardController.onDragMailbox(isDrag); } - Future swipeEmailAction(BuildContext context, PresentationEmail email, DismissDirection direction) async { + Future swipeEmailAction( + PresentationEmail email, + DismissDirection direction, + ) async { if (direction == DismissDirection.startToEnd) { ReadActions readActions = !email.hasRead ? ReadActions.markAsRead : ReadActions.markAsUnread; markAsEmailRead(email, readActions, MarkReadAction.swipeOnThread); diff --git a/lib/features/thread/presentation/thread_view.dart b/lib/features/thread/presentation/thread_view.dart index 84953464c..35ca8832a 100644 --- a/lib/features/thread/presentation/thread_view.dart +++ b/lib/features/thread/presentation/thread_view.dart @@ -10,6 +10,7 @@ import 'package:tmail_ui_user/features/base/mixin/app_loader_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/popup_menu_widget_mixin.dart'; import 'package:tmail_ui_user/features/base/widget/clean_messages_banner.dart'; import 'package:tmail_ui_user/features/base/widget/compose_floating_button.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/report_message_banner.dart'; import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; @@ -31,10 +32,12 @@ import 'package:tmail_ui_user/features/thread/domain/state/empty_spam_folder_sta import 'package:tmail_ui_user/features/thread/domain/state/empty_trash_folder_state.dart'; import 'package:tmail_ui_user/features/thread/domain/state/get_all_email_state.dart'; import 'package:tmail_ui_user/features/thread/domain/state/search_email_state.dart'; +import 'package:tmail_ui_user/features/thread/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/thread/presentation/extensions/handle_open_context_menu_filter_email_action_extension.dart'; import 'package:tmail_ui_user/features/thread/presentation/extensions/handle_press_email_selection_action.dart'; import 'package:tmail_ui_user/features/thread/presentation/extensions/handle_pull_to_refresh_list_email_extension.dart'; import 'package:tmail_ui_user/features/thread/presentation/extensions/handle_select_message_filter_extension.dart'; +import 'package:tmail_ui_user/features/thread/presentation/extensions/handle_shift_selection_email_extension.dart'; import 'package:tmail_ui_user/features/thread/presentation/model/delete_action_type.dart'; import 'package:tmail_ui_user/features/thread/presentation/model/loading_more_status.dart'; import 'package:tmail_ui_user/features/thread/presentation/styles/item_email_tile_styles.dart'; @@ -57,238 +60,246 @@ class ThreadView extends GetWidget @override Widget build(BuildContext context) { - return GestureDetector( - onTap: FocusManager.instance.primaryFocus?.unfocus, - child: Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Colors.white, - body: Portal( - child: Row(children: [ - if (_supportVerticalDivider(context)) - const VerticalDivider(), - Expanded(child: SafeArea( - right: controller.responsiveUtils.isLandscapeMobile(context), - left: controller.responsiveUtils.isLandscapeMobile(context), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!controller.responsiveUtils.isWebDesktop(context)) - ... [ - Obx(() { - return MobileAppBarThreadWidget( - responsiveUtils: controller.responsiveUtils, - imagePaths: controller.imagePaths, - mailboxSelected: controller.selectedMailbox, - listEmailSelected: controller.mailboxDashBoardController.emailsInCurrentMailbox.listEmailSelected, - selectMode: controller.mailboxDashBoardController.currentSelectMode.value, - filterOption: controller.mailboxDashBoardController.filterMessageOption.value, - openMailboxAction: controller.openMailboxLeftMenu, - onCancelSelectionAction: controller.cancelSelectEmail, - onContextMenuFilterEmailAction: controller.responsiveUtils.isScreenWithShortestSide(context) - ? (filterOption) => controller.handleSelectMessageFilter(context, filterOption) - : null, - onPopupMenuFilterEmailAction: !controller.responsiveUtils.isScreenWithShortestSide(context) - ? (filterOption, position) => controller.handleOpenContextMenuFilterEmailAction(context, position, filterOption) - : null, - onPressEmailSelectionActionClick: (type, emails) => - controller.handlePressEmailSelectionAction( - context, - type, - emails, - controller.selectedMailbox, - ), - ); - }), - if (PlatformInfo.isMobile) - Obx(() { - if (!controller.networkConnectionController.isNetworkConnectionAvailable()) { - return NetworkConnectionBannerWidget(); - } else { - return const SizedBox.shrink(); - } - }), - SearchBarView( - key: const Key('email_search_bar_view'), + final bodyWidget = Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: Colors.white, + body: Portal( + child: Row(children: [ + if (_supportVerticalDivider(context)) + const VerticalDivider(), + Expanded(child: SafeArea( + right: controller.responsiveUtils.isLandscapeMobile(context), + left: controller.responsiveUtils.isLandscapeMobile(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!controller.responsiveUtils.isWebDesktop(context)) + ... [ + Obx(() { + return MobileAppBarThreadWidget( + responsiveUtils: controller.responsiveUtils, imagePaths: controller.imagePaths, - margin: ThreadViewStyle.getBannerMargin( - context, - controller.responsiveUtils), - hintTextSearch: AppLocalizations.of(context).search_emails, - onOpenSearchViewAction: controller.goToSearchView - ), - Obx(() { - final spamController = controller - .mailboxDashBoardController - .spamReportController; - - final isSpamReportDisabled = spamController.spamReportState.value == SpamReportState.disabled; - - final isSpamFolderSelected = controller - .mailboxDashBoardController - .selectedMailbox - .value - ?.isSpam == true; - - final isPresentationSpamMailboxIsNull = spamController.presentationSpamMailbox.value == null; - - if (isSpamReportDisabled || - isPresentationSpamMailboxIsNull || - isSpamFolderSelected) { - return const SizedBox.shrink(); - } - - return ReportMessageBanner( - imagePaths: controller.imagePaths, - message: AppLocalizations - .of(context) - .countMessageInSpam( - spamController.numberOfUnreadSpamEmails, - ), - positiveName: AppLocalizations.of(context).view, - margin: ThreadViewStyle.getBannerMargin( - context, - controller.responsiveUtils, - ), - isDesktop: controller - .responsiveUtils - .isDesktop(context), - onPositiveAction: spamController.openMailbox, - onNegativeAction: () => - spamController.dismissSpamReportAction( + mailboxSelected: controller.selectedMailbox, + listEmailSelected: controller.mailboxDashBoardController.emailsInCurrentMailbox.listEmailSelected, + selectMode: controller.mailboxDashBoardController.currentSelectMode.value, + filterOption: controller.mailboxDashBoardController.filterMessageOption.value, + openMailboxAction: controller.openMailboxLeftMenu, + onCancelSelectionAction: controller.cancelSelectEmail, + onContextMenuFilterEmailAction: controller.responsiveUtils.isScreenWithShortestSide(context) + ? (filterOption) => controller.handleSelectMessageFilter(context, filterOption) + : null, + onPopupMenuFilterEmailAction: !controller.responsiveUtils.isScreenWithShortestSide(context) + ? (filterOption, position) => controller.handleOpenContextMenuFilterEmailAction(context, position, filterOption) + : null, + onPressEmailSelectionActionClick: (type, emails) => + controller.handlePressEmailSelectionAction( context, + type, + emails, + controller.selectedMailbox, ), ); - }), - QuotasBannerWidget(), + }), + if (PlatformInfo.isMobile) Obx(() { - final vacation = controller.mailboxDashBoardController.vacationResponse.value; - if (vacation?.vacationResponderIsValid == true) { - return VacationNotificationMessageWidget( - margin: ThreadViewStyle.getBannerMargin( - context, - controller.responsiveUtils), - vacationResponse: vacation!, - actionGotoVacationSetting: controller.mailboxDashBoardController.goToVacationSetting, - actionEndNow: controller.mailboxDashBoardController.disableVacationResponder - ); + if (!controller.networkConnectionController.isNetworkConnectionAvailable()) { + return NetworkConnectionBannerWidget(); } else { return const SizedBox.shrink(); } }), - Obx(() => RecoverDeletedMessageLoadingBannerWidget( - isLoading: controller.mailboxDashBoardController.isRecoveringDeletedMessage.value, - horizontalLoadingWidget: horizontalLoadingWidget, - responsiveUtils: controller.responsiveUtils, - )), - ], - Obx(() { - final dashboardController = controller - .mailboxDashBoardController; - final selectedMailbox = dashboardController - .selectedMailbox - .value; - - bool showTrashBanner = dashboardController - .isEmptyTrashBannerEnabledOnMobile( + SearchBarView( + key: const Key('email_search_bar_view'), + imagePaths: controller.imagePaths, + margin: ThreadViewStyle.getBannerMargin( context, - selectedMailbox, - ); - bool showSpamBanner = dashboardController - .isEmptySpamBannerEnabledOnMobile( - context, - selectedMailbox, - ); - - if (showTrashBanner) { - return CleanMessagesBanner( - responsiveUtils: controller.responsiveUtils, - key: const Key('empty_trash_banner'), - message: AppLocalizations - .of(context) - .message_delete_all_email_in_trash_button, - positiveAction: AppLocalizations - .of(context) - .empty_trash_now, - onPositiveAction: () => - controller.deleteSelectionEmailsPermanently( - context, - DeleteActionType.all, - ), - margin: ThreadViewStyle.getBannerMargin( - context, - controller.responsiveUtils, - ), - ); - } else if (showSpamBanner) { - return CleanMessagesBanner( - responsiveUtils: controller.responsiveUtils, - message: AppLocalizations - .of(context) - .bannerDeleteAllSpamEmailsMessage, - positiveAction: AppLocalizations - .of(context) - .deleteAllSpamEmailsNow, - onPositiveAction: () => controller + controller.responsiveUtils), + hintTextSearch: AppLocalizations.of(context).search_emails, + onOpenSearchViewAction: controller.goToSearchView + ), + Obx(() { + final spamController = controller .mailboxDashBoardController - .openDialogEmptySpamFolder(context), + .spamReportController; + + final isSpamReportDisabled = spamController.spamReportState.value == SpamReportState.disabled; + + final isSpamFolderSelected = controller + .mailboxDashBoardController + .selectedMailbox + .value + ?.isSpam == true; + + final isPresentationSpamMailboxIsNull = spamController.presentationSpamMailbox.value == null; + + if (isSpamReportDisabled || + isPresentationSpamMailboxIsNull || + isSpamFolderSelected) { + return const SizedBox.shrink(); + } + + return ReportMessageBanner( + imagePaths: controller.imagePaths, + message: AppLocalizations + .of(context) + .countMessageInSpam( + spamController.numberOfUnreadSpamEmails, + ), + positiveName: AppLocalizations.of(context).view, margin: ThreadViewStyle.getBannerMargin( context, controller.responsiveUtils, ), - ); - } else { - return const SizedBox.shrink( - key: Key('clean_message_banner_not_visible'), - ); - } - }), - if (!controller.responsiveUtils.isDesktop(context)) - _buildMailboxActionProgressBanner(context), - Obx(() => ThreadViewLoadingBarWidget(viewState: controller.viewState.value)), - Expanded( - child: Container( - alignment: Alignment.center, - color: Colors.white, - child: Obx(() { - return Visibility( - visible: controller.openingEmail.isFalse, - child: _buildResultListEmail( + isDesktop: controller + .responsiveUtils + .isDesktop(context), + onPositiveAction: spamController.openMailbox, + onNegativeAction: () => + spamController.dismissSpamReportAction( context, - controller.mailboxDashBoardController.emailsInCurrentMailbox - ) + ), + ); + }), + QuotasBannerWidget(), + Obx(() { + final vacation = controller.mailboxDashBoardController.vacationResponse.value; + if (vacation?.vacationResponderIsValid == true) { + return VacationNotificationMessageWidget( + margin: ThreadViewStyle.getBannerMargin( + context, + controller.responsiveUtils), + vacationResponse: vacation!, + actionGotoVacationSetting: controller.mailboxDashBoardController.goToVacationSetting, + actionEndNow: controller.mailboxDashBoardController.disableVacationResponder ); - }) - ) - ), - ] - ) - )) - ]), - ), - floatingActionButton: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Container( - padding: const EdgeInsetsDirectional.only(end: 4.0), - child: ScrollToTopButtonWidget( - scrollController: controller.listEmailController, - onTap: controller.scrollToTop, - responsiveUtils: controller.responsiveUtils, - icon: SvgPicture.asset( - controller.imagePaths.icArrowUpOutline, - width: ScrollToTopButtonWidgetStyles.iconWidth, - height: ScrollToTopButtonWidgetStyles.iconHeight, - fit: BoxFit.fill, - colorFilter: Colors.white.asFilter(), - ), + } else { + return const SizedBox.shrink(); + } + }), + Obx(() => RecoverDeletedMessageLoadingBannerWidget( + isLoading: controller.mailboxDashBoardController.isRecoveringDeletedMessage.value, + horizontalLoadingWidget: horizontalLoadingWidget, + responsiveUtils: controller.responsiveUtils, + )), + ], + Obx(() { + final dashboardController = controller + .mailboxDashBoardController; + final selectedMailbox = dashboardController + .selectedMailbox + .value; + + bool showTrashBanner = dashboardController + .isEmptyTrashBannerEnabledOnMobile( + context, + selectedMailbox, + ); + bool showSpamBanner = dashboardController + .isEmptySpamBannerEnabledOnMobile( + context, + selectedMailbox, + ); + + if (showTrashBanner) { + return CleanMessagesBanner( + responsiveUtils: controller.responsiveUtils, + key: const Key('empty_trash_banner'), + message: AppLocalizations + .of(context) + .message_delete_all_email_in_trash_button, + positiveAction: AppLocalizations + .of(context) + .empty_trash_now, + onPositiveAction: () => + controller.deleteSelectionEmailsPermanently( + context, + DeleteActionType.all, + ), + margin: ThreadViewStyle.getBannerMargin( + context, + controller.responsiveUtils, + ), + ); + } else if (showSpamBanner) { + return CleanMessagesBanner( + responsiveUtils: controller.responsiveUtils, + message: AppLocalizations + .of(context) + .bannerDeleteAllSpamEmailsMessage, + positiveAction: AppLocalizations + .of(context) + .deleteAllSpamEmailsNow, + onPositiveAction: () => controller + .mailboxDashBoardController + .openDialogEmptySpamFolder(context), + margin: ThreadViewStyle.getBannerMargin( + context, + controller.responsiveUtils, + ), + ); + } else { + return const SizedBox.shrink( + key: Key('clean_message_banner_not_visible'), + ); + } + }), + if (!controller.responsiveUtils.isDesktop(context)) + _buildMailboxActionProgressBanner(context), + Obx(() => ThreadViewLoadingBarWidget(viewState: controller.viewState.value)), + Expanded( + child: Container( + alignment: Alignment.center, + color: Colors.white, + child: Obx(() { + return Visibility( + visible: controller.openingEmail.isFalse, + child: _buildResultListEmail( + context, + controller.mailboxDashBoardController.emailsInCurrentMailbox + ) + ); + }) + ) + ), + ] + ) + )) + ]), + ), + floatingActionButton: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + padding: const EdgeInsetsDirectional.only(end: 4.0), + child: ScrollToTopButtonWidget( + scrollController: controller.listEmailController, + onTap: controller.scrollToTop, + responsiveUtils: controller.responsiveUtils, + icon: SvgPicture.asset( + controller.imagePaths.icArrowUpOutline, + width: ScrollToTopButtonWidgetStyles.iconWidth, + height: ScrollToTopButtonWidgetStyles.iconHeight, + fit: BoxFit.fill, + colorFilter: Colors.white.asFilter(), ), ), - const SizedBox(height: 24), - _buildFloatingButtonCompose(context), - ], - ), + ), + const SizedBox(height: 24), + _buildFloatingButtonCompose(context), + ], ), ); + + if (PlatformInfo.isWeb && controller.keyboardFocusNode != null) { + return KeyboardHandlerWrapper( + onKeyDownEventAction: controller.onKeyDownEventAction, + onKeyUpEventAction: controller.onKeyUpEventAction, + focusNode: controller.keyboardFocusNode!, + child: bodyWidget, + ); + } else { + return bodyWidget; + } } bool _supportVerticalDivider(BuildContext context) { @@ -409,14 +420,7 @@ class ThreadView extends GetWidget return NotificationListener( onNotification: _handleScrollNotificationListener, - child: PlatformInfo.isMobile - ? listView - : Focus( - focusNode: controller.focusNodeKeyBoard, - autofocus: true, - onKeyEvent: controller.handleKeyEvent, - child: listView, - ), + child: listView, ); } @@ -582,7 +586,10 @@ class ThreadView extends GetWidget ), ) : null, - confirmDismiss: (direction) => controller.swipeEmailAction(context, presentationEmail, direction), + confirmDismiss: (direction) => controller.swipeEmailAction( + presentationEmail, + direction, + ), child: EmailTileBuilder( key: Key('email_tile_builder_${presentationEmail.id?.asString}'), presentationEmail: presentationEmail, diff --git a/lib/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart b/lib/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart index 236880ad6..6b46759cb 100644 --- a/lib/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart +++ b/lib/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart @@ -35,9 +35,6 @@ extension HandleMailShortcutActionsExtension on ThreadDetailController { void onKeyboardShortcutInit() { if (PlatformInfo.isWeb) { keyboardShortcutFocusNode = FocusNode(); - keyboardShortcutFocusNode?.addListener(() { - log('$runtimeType::onInit:keyboardShortcutFocusNode: ${keyboardShortcutFocusNode?.hasFocus}'); - }); shortcutActionEventController = StreamController(); shortcutActionEventSubscription = shortcutActionEventController