From b6027e996dbaf15a52f64010b9cd7b70d0be1a30 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 11 Dec 2025 17:00:10 +0700 Subject: [PATCH] TF-4195 Show submenu when hover Label As item --- .../base/model/popup_menu_item_action.dart | 31 ++- .../popup_menu_action_group_widget.dart | 33 ++- .../popup_menu_item_action_widget.dart | 195 ++++++++++++++---- .../popup_menu/popup_submenu_controller.dart | 80 +++++++ .../email/presentation/email_view.dart | 2 + .../model/popup_menu_item_email_action.dart | 4 + .../email_action_reactor.dart | 35 +++- .../widgets/label_item_context_menu.dart | 58 ++++++ .../widgets/label_list_context_menu.dart | 38 ++++ .../model/popup_menu_item_mailbox_action.dart | 3 + ...menu_item_profile_setting_type_action.dart | 3 + 11 files changed, 439 insertions(+), 43 deletions(-) create mode 100644 lib/features/base/widget/popup_menu/popup_submenu_controller.dart create mode 100644 lib/features/labels/presentation/widgets/label_item_context_menu.dart create mode 100644 lib/features/labels/presentation/widgets/label_list_context_menu.dart diff --git a/lib/features/base/model/popup_menu_item_action.dart b/lib/features/base/model/popup_menu_item_action.dart index 1cbffb5ba..9e5351668 100644 --- a/lib/features/base/model/popup_menu_item_action.dart +++ b/lib/features/base/model/popup_menu_item_action.dart @@ -3,16 +3,23 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; typedef OnPopupMenuActionClick = void Function(PopupMenuItemAction action); +typedef OnHoverShowSubmenu = void Function(GlobalKey key); abstract class PopupMenuItemAction with EquatableMixin { final T action; final String? key; final int category; + final Widget? submenu; - PopupMenuItemAction(this.action, {this.key, this.category = -1}); + PopupMenuItemAction( + this.action, { + this.key, + this.category = -1, + this.submenu, + }); @override - List get props => [action, key, category]; + List get props => [action, key, category, submenu]; String get actionName; @@ -40,9 +47,22 @@ mixin OptionalPopupSelectedIcon { double get selectedIconSize => 16.0; } +mixin OptionalPopupHoverIcon { + String get hoverIcon; + + Color get hoverIconColor => AppColor.steelGrayA540; + + double get hoverIconSize => 16.0; +} + abstract class PopupMenuItemActionRequiredIcon extends PopupMenuItemAction - with OptionalPopupIcon { - PopupMenuItemActionRequiredIcon(super.action, {super.key, super.category}); + with OptionalPopupIcon, OptionalPopupHoverIcon { + PopupMenuItemActionRequiredIcon( + super.action, { + super.key, + super.category, + super.submenu, + }); } abstract class PopupMenuItemActionRequiredSelectedIcon @@ -54,6 +74,7 @@ abstract class PopupMenuItemActionRequiredSelectedIcon this.selectedAction, { super.key, super.category, + super.submenu, }); } @@ -66,6 +87,7 @@ abstract class PopupMenuItemActionRequiredFull extends PopupMenuItemAction this.selectedAction, { super.key, super.category, + super.submenu, }); } @@ -79,5 +101,6 @@ abstract class PopupMenuItemActionRequiredIconWithMultipleSelected this.selectedActions, { super.key, super.category, + super.submenu, }); } diff --git a/lib/features/base/widget/popup_menu/popup_menu_action_group_widget.dart b/lib/features/base/widget/popup_menu/popup_menu_action_group_widget.dart index 04eab5b5b..a3354ec89 100644 --- a/lib/features/base/widget/popup_menu/popup_menu_action_group_widget.dart +++ b/lib/features/base/widget/popup_menu/popup_menu_action_group_widget.dart @@ -4,6 +4,7 @@ import 'package:tmail_ui_user/features/base/extensions/popup_menu_action_list_ex import 'package:tmail_ui_user/features/base/mixin/popup_context_menu_action_mixin.dart'; import 'package:tmail_ui_user/features/base/model/popup_menu_item_action.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/popup_menu/popup_submenu_controller.dart'; typedef OnPopupMenuActionSelected = void Function(PopupMenuItemAction action); @@ -11,11 +12,13 @@ class PopupMenuActionGroupWidget with PopupContextMenuActionMixin { final List actions; final OnPopupMenuActionSelected onActionSelected; final double dividerOpacity; + final PopupSubmenuController? submenuController; - const PopupMenuActionGroupWidget({ + PopupMenuActionGroupWidget({ required this.actions, required this.onActionSelected, this.dividerOpacity = 0.12, + this.submenuController, }); Future show( @@ -37,6 +40,15 @@ class PopupMenuActionGroupWidget with PopupContextMenuActionMixin { Navigator.pop(context); onActionSelected(menuAction); }, + onHoverShowSubmenu: submenuController != null && menuAction.submenu != null + ? (itemKey) => _showPopupSubmenu( + context: context, + itemKey: itemKey, + submenuController: submenuController!, + submenu: menuAction.submenu!, + ) + : null, + onHoverOtherItem: submenuController?.hide, ), ), ), @@ -50,4 +62,23 @@ class PopupMenuActionGroupWidget with PopupContextMenuActionMixin { return openPopupMenuAction(context, position, popupMenuItems); } + + void _showPopupSubmenu({ + required BuildContext context, + required GlobalKey itemKey, + required PopupSubmenuController submenuController, + required Widget submenu, +}) { + final renderBox = itemKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final offset = renderBox.localToGlobal(Offset.zero); + final rect = offset & renderBox.size; + + submenuController.show( + context: context, + anchor: rect, + submenu: submenu, + ); + } } diff --git a/lib/features/base/widget/popup_menu/popup_menu_item_action_widget.dart b/lib/features/base/widget/popup_menu/popup_menu_item_action_widget.dart index 281534d1d..752be30cb 100644 --- a/lib/features/base/widget/popup_menu/popup_menu_item_action_widget.dart +++ b/lib/features/base/widget/popup_menu/popup_menu_item_action_widget.dart @@ -2,27 +2,52 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/utils/theme_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:model/email/email_action_type.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:tmail_ui_user/features/base/model/popup_menu_item_action.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/popup_menu_item_email_action.dart'; -class PopupMenuItemActionWidget extends StatelessWidget { +class PopupMenuItemActionWidget extends StatefulWidget { final PopupMenuItemAction menuAction; final OnPopupMenuActionClick menuActionClick; + final OnHoverShowSubmenu? onHoverShowSubmenu; + final VoidCallback? onHoverOtherItem; const PopupMenuItemActionWidget({ super.key, required this.menuAction, required this.menuActionClick, + this.onHoverShowSubmenu, + this.onHoverOtherItem, }); + @override + State createState() => + _PopupMenuItemActionWidgetState(); +} + +class _PopupMenuItemActionWidgetState extends State { + GlobalKey? _itemKey; + ValueNotifier? _hoverItemNotifier; + + @override + void initState() { + super.initState(); + if (widget.menuAction is PopupMenuItemEmailAction) { + _itemKey = GlobalKey(); + _hoverItemNotifier = ValueNotifier(false); + } + } + @override Widget build(BuildContext context) { Widget? iconWidget; Widget? selectedIconWidget; bool isSelected = false; - if (menuAction is PopupMenuItemActionRequiredIcon) { - final specificMenuAction = menuAction as PopupMenuItemActionRequiredIcon; + if (widget.menuAction is PopupMenuItemActionRequiredIcon) { + final specificMenuAction = + widget.menuAction as PopupMenuItemActionRequiredIcon; iconWidget = Padding( padding: const EdgeInsetsDirectional.only(end: 16), child: SvgPicture.asset( @@ -33,9 +58,9 @@ class PopupMenuItemActionWidget extends StatelessWidget { fit: BoxFit.fill, ), ); - } else if (menuAction is PopupMenuItemActionRequiredSelectedIcon) { + } else if (widget.menuAction is PopupMenuItemActionRequiredSelectedIcon) { final specificMenuAction = - menuAction as PopupMenuItemActionRequiredSelectedIcon; + widget.menuAction as PopupMenuItemActionRequiredSelectedIcon; selectedIconWidget = Padding( padding: const EdgeInsetsDirectional.only(start: 16), child: SvgPicture.asset( @@ -46,34 +71,11 @@ class PopupMenuItemActionWidget extends StatelessWidget { fit: BoxFit.fill, ), ); - isSelected = specificMenuAction.selectedAction == menuAction.action; - } else if (menuAction is PopupMenuItemActionRequiredFull) { - final specificMenuAction = menuAction as PopupMenuItemActionRequiredFull; - iconWidget = Padding( - padding: const EdgeInsetsDirectional.only(end: 16), - child: SvgPicture.asset( - specificMenuAction.actionIcon, - width: specificMenuAction.actionIconSize, - height: specificMenuAction.actionIconSize, - colorFilter: specificMenuAction.actionIconColor.asFilter(), - fit: BoxFit.fill, - ), - ); - selectedIconWidget = Padding( - padding: const EdgeInsetsDirectional.only(start: 16), - child: SvgPicture.asset( - specificMenuAction.selectedIcon, - width: specificMenuAction.selectedIconSize, - height: specificMenuAction.selectedIconSize, - colorFilter: specificMenuAction.selectedIconColor.asFilter(), - fit: BoxFit.fill, - ), - ); - isSelected = specificMenuAction.selectedAction == menuAction.action; - } else if (menuAction - is PopupMenuItemActionRequiredIconWithMultipleSelected) { + isSelected = + specificMenuAction.selectedAction == widget.menuAction.action; + } else if (widget.menuAction is PopupMenuItemActionRequiredFull) { final specificMenuAction = - menuAction as PopupMenuItemActionRequiredIconWithMultipleSelected; + widget.menuAction as PopupMenuItemActionRequiredFull; iconWidget = Padding( padding: const EdgeInsetsDirectional.only(end: 16), child: SvgPicture.asset( @@ -95,27 +97,137 @@ class PopupMenuItemActionWidget extends StatelessWidget { ), ); isSelected = - specificMenuAction.selectedActions.contains(menuAction.action); + specificMenuAction.selectedAction == widget.menuAction.action; + } else if (widget.menuAction + is PopupMenuItemActionRequiredIconWithMultipleSelected) { + final specificMenuAction = widget.menuAction + as PopupMenuItemActionRequiredIconWithMultipleSelected; + iconWidget = Padding( + padding: const EdgeInsetsDirectional.only(end: 16), + child: SvgPicture.asset( + specificMenuAction.actionIcon, + width: specificMenuAction.actionIconSize, + height: specificMenuAction.actionIconSize, + colorFilter: specificMenuAction.actionIconColor.asFilter(), + fit: BoxFit.fill, + ), + ); + selectedIconWidget = Padding( + padding: const EdgeInsetsDirectional.only(start: 16), + child: SvgPicture.asset( + specificMenuAction.selectedIcon, + width: specificMenuAction.selectedIconSize, + height: specificMenuAction.selectedIconSize, + colorFilter: specificMenuAction.selectedIconColor.asFilter(), + fit: BoxFit.fill, + ), + ); + isSelected = + specificMenuAction.selectedActions.contains(widget.menuAction.action); + } + + if (widget.menuAction is PopupMenuItemEmailAction && + widget.menuAction.action == EmailActionType.labelAs) { + final specificMenuAction = widget.menuAction as PopupMenuItemEmailAction; + + return PointerInterceptor( + child: MouseRegion( + onEnter: (_) { + if (_hoverItemNotifier != null) { + _hoverItemNotifier?.value = true; + } + if (_itemKey != null) { + widget.onHoverShowSubmenu?.call(_itemKey!); + } else { + widget.onHoverOtherItem?.call(); + } + }, + onExit: (_) { + if (_hoverItemNotifier != null) { + _hoverItemNotifier?.value = false; + } + }, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => specificMenuAction.onClick(widget.menuActionClick), + hoverColor: AppColor.popupMenuItemHovered, + child: Container( + key: _itemKey, + height: 48, + width: double.infinity, + padding: specificMenuAction.itemPadding, + child: Row( + children: [ + if (iconWidget != null) iconWidget, + Expanded( + child: Text( + specificMenuAction.actionName, + style: ThemeUtils.textStyleBodyBody3( + color: specificMenuAction.actionNameColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected && selectedIconWidget != null) + selectedIconWidget, + if (_hoverItemNotifier != null) + ValueListenableBuilder( + valueListenable: _hoverItemNotifier!, + builder: (_, value, __) { + if (value) { + return Padding( + padding: + const EdgeInsetsDirectional.only(start: 16), + child: SvgPicture.asset( + specificMenuAction.hoverIcon, + width: specificMenuAction.hoverIconSize, + height: specificMenuAction.hoverIconSize, + colorFilter: specificMenuAction.hoverIconColor + .asFilter(), + fit: BoxFit.fill, + ), + ); + } else { + return const SizedBox.shrink(); + } + }, + ) + ], + ), + ), + ), + ), + ), + ); } return PointerInterceptor( child: Material( type: MaterialType.transparency, child: InkWell( - onTap: () => menuAction.onClick(menuActionClick), + onTap: () => widget.menuAction.onClick(widget.menuActionClick), hoverColor: AppColor.popupMenuItemHovered, + onHover: (_) { + if (_hoverItemNotifier != null) { + _hoverItemNotifier?.value = false; + } + widget.onHoverOtherItem?.call(); + }, child: Container( + key: _itemKey, height: 48, width: double.infinity, - padding: menuAction.itemPadding, + padding: widget.menuAction.itemPadding, child: Row( children: [ if (iconWidget != null) iconWidget, Expanded( child: Text( - menuAction.actionName, + widget.menuAction.actionName, style: ThemeUtils.textStyleBodyBody3( - color: menuAction.actionNameColor, + color: widget.menuAction.actionNameColor, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -130,4 +242,13 @@ class PopupMenuItemActionWidget extends StatelessWidget { ), ); } + + @override + void dispose() { + if (_hoverItemNotifier != null) { + _hoverItemNotifier?.dispose(); + _hoverItemNotifier = null; + } + super.dispose(); + } } diff --git a/lib/features/base/widget/popup_menu/popup_submenu_controller.dart b/lib/features/base/widget/popup_menu/popup_submenu_controller.dart new file mode 100644 index 000000000..24261dddd --- /dev/null +++ b/lib/features/base/widget/popup_menu/popup_submenu_controller.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +enum SubmenuDirection { left, right, auto } + +class PopupSubmenuController { + OverlayEntry? _submenuEntry; + + bool get isShowing => _submenuEntry != null; + + void show({ + required BuildContext context, + required Rect anchor, + required Widget submenu, + SubmenuDirection direction = SubmenuDirection.auto, + double submenuWidth = 249, + double submenuMaxHeight = 400, + double offset = 0, + }) { + hide(); + + final screenWidth = MediaQuery.of(context).size.width; + + final rightPosition = anchor.right + offset; + final leftPosition = anchor.left - submenuWidth - offset; + + bool canShowRight = rightPosition + submenuWidth <= screenWidth; + bool canShowLeft = leftPosition >= 0; + + double? finalLeft; + + if (direction == SubmenuDirection.right) { + finalLeft = rightPosition; + } else if (direction == SubmenuDirection.left) { + finalLeft = leftPosition; + } else { + if (canShowRight) { + finalLeft = rightPosition; + } else if (canShowLeft) { + finalLeft = leftPosition; + } else { + finalLeft = rightPosition; + } + } + + _submenuEntry = OverlayEntry( + builder: (_) { + return Positioned( + left: finalLeft, + top: anchor.top, + child: MouseRegion( + onExit: (_) => hide(), + child: Material( + elevation: 8, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + child: SizedBox( + width: submenuWidth, + height: submenuMaxHeight, + child: submenu, + ), + ), + ), + ); + }, + ); + + Overlay.of(context).insert(_submenuEntry!); + } + + void hide() { + _submenuEntry?.remove(); + _submenuEntry = null; + } + + void dispose() { + hide(); + } +} diff --git a/lib/features/email/presentation/email_view.dart b/lib/features/email/presentation/email_view.dart index c6688fb05..f82303bab 100644 --- a/lib/features/email/presentation/email_view.dart +++ b/lib/features/email/presentation/email_view.dart @@ -103,6 +103,7 @@ class EmailView extends GetWidget { additionalActions: [], emailIsRead: presentationEmail.hasRead, isLabelFeatureEnabled: controller.isLabelFeatureEnabled, + labels: controller.mailboxDashBoardController.labelController.labels, openBottomSheetContextMenu: controller.mailboxDashBoardController.openBottomSheetContextMenu, openPopupMenu: controller.mailboxDashBoardController.openPopupMenuActionGroup, ); @@ -309,6 +310,7 @@ class EmailView extends GetWidget { ], emailIsRead: presentationEmail.hasRead, isLabelFeatureEnabled: controller.isLabelFeatureEnabled, + labels: controller.mailboxDashBoardController.labelController.labels, openBottomSheetContextMenu: controller.mailboxDashBoardController.openBottomSheetContextMenu, openPopupMenu: controller.mailboxDashBoardController.openPopupMenuActionGroup, ), diff --git a/lib/features/email/presentation/model/popup_menu_item_email_action.dart b/lib/features/email/presentation/model/popup_menu_item_email_action.dart index 1071e4952..a9dcbb834 100644 --- a/lib/features/email/presentation/model/popup_menu_item_email_action.dart +++ b/lib/features/email/presentation/model/popup_menu_item_email_action.dart @@ -16,6 +16,7 @@ class PopupMenuItemEmailAction this.imagePaths, { super.key, super.category, + super.submenu, }); @override @@ -29,4 +30,7 @@ class PopupMenuItemEmailAction @override Color get actionNameColor => action.getPopupMenuTitleColor(); + + @override + String get hoverIcon => imagePaths.icThumbsUp; } diff --git a/lib/features/email/presentation/utils/email_action_reactor/email_action_reactor.dart b/lib/features/email/presentation/utils/email_action_reactor/email_action_reactor.dart index 7d4f5341b..b3d8696a1 100644 --- a/lib/features/email/presentation/utils/email_action_reactor/email_action_reactor.dart +++ b/lib/features/email/presentation/utils/email_action_reactor/email_action_reactor.dart @@ -20,6 +20,7 @@ import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:labels/model/label.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/mark_star_action.dart'; import 'package:model/email/presentation_email.dart'; @@ -32,6 +33,7 @@ import 'package:model/mailbox/presentation_mailbox.dart'; import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_manager.dart'; import 'package:tmail_ui_user/features/base/widget/context_menu/context_menu_item_action.dart'; import 'package:tmail_ui_user/features/base/widget/popup_menu/popup_menu_action_group_widget.dart'; +import 'package:tmail_ui_user/features/base/widget/popup_menu/popup_submenu_controller.dart'; import 'package:tmail_ui_user/features/destination_picker/presentation/model/destination_picker_arguments.dart'; import 'package:tmail_ui_user/features/email/domain/model/email_print.dart'; import 'package:tmail_ui_user/features/email/domain/model/mark_read_action.dart'; @@ -50,6 +52,7 @@ import 'package:tmail_ui_user/features/email/presentation/model/popup_menu_item_ import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_address_bottom_sheet_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_address_dialog_builder.dart'; +import 'package:tmail_ui_user/features/labels/presentation/widgets/label_list_context_menu.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_actions.dart'; import 'package:tmail_ui_user/features/manage_account/domain/model/create_new_email_rule_filter_request.dart'; import 'package:tmail_ui_user/features/manage_account/domain/usecases/create_new_email_rule_filter_interactor.dart'; @@ -486,6 +489,7 @@ class EmailActionReactor { required bool isLabelFeatureEnabled, required OpenBottomSheetContextMenuAction openBottomSheetContextMenu, required OpenPopupMenuActionGroup openPopupMenu, + List