TF-4195 Show submenu when hover Label As item

This commit is contained in:
dab246
2025-12-11 17:00:10 +07:00
committed by Dat H. Pham
parent 03b3d1181f
commit b6027e996d
11 changed files with 439 additions and 43 deletions
@@ -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<T> 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<Object?> get props => [action, key, category];
List<Object?> get props => [action, key, category, submenu];
String get actionName;
@@ -40,9 +47,22 @@ mixin OptionalPopupSelectedIcon<T> {
double get selectedIconSize => 16.0;
}
mixin OptionalPopupHoverIcon {
String get hoverIcon;
Color get hoverIconColor => AppColor.steelGrayA540;
double get hoverIconSize => 16.0;
}
abstract class PopupMenuItemActionRequiredIcon<T> extends PopupMenuItemAction<T>
with OptionalPopupIcon {
PopupMenuItemActionRequiredIcon(super.action, {super.key, super.category});
with OptionalPopupIcon, OptionalPopupHoverIcon {
PopupMenuItemActionRequiredIcon(
super.action, {
super.key,
super.category,
super.submenu,
});
}
abstract class PopupMenuItemActionRequiredSelectedIcon<T>
@@ -54,6 +74,7 @@ abstract class PopupMenuItemActionRequiredSelectedIcon<T>
this.selectedAction, {
super.key,
super.category,
super.submenu,
});
}
@@ -66,6 +87,7 @@ abstract class PopupMenuItemActionRequiredFull<T> extends PopupMenuItemAction<T>
this.selectedAction, {
super.key,
super.category,
super.submenu,
});
}
@@ -79,5 +101,6 @@ abstract class PopupMenuItemActionRequiredIconWithMultipleSelected<T>
this.selectedActions, {
super.key,
super.category,
super.submenu,
});
}
@@ -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<PopupMenuItemAction> 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<void> 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,
);
}
}
@@ -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<PopupMenuItemActionWidget> createState() =>
_PopupMenuItemActionWidgetState();
}
class _PopupMenuItemActionWidgetState extends State<PopupMenuItemActionWidget> {
GlobalKey? _itemKey;
ValueNotifier<bool>? _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();
}
}
@@ -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();
}
}
@@ -103,6 +103,7 @@ class EmailView extends GetWidget<SingleEmailController> {
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<SingleEmailController> {
],
emailIsRead: presentationEmail.hasRead,
isLabelFeatureEnabled: controller.isLabelFeatureEnabled,
labels: controller.mailboxDashBoardController.labelController.labels,
openBottomSheetContextMenu: controller.mailboxDashBoardController.openBottomSheetContextMenu,
openPopupMenu: controller.mailboxDashBoardController.openPopupMenuActionGroup,
),
@@ -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;
}
@@ -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<Label>? labels,
}) {
if (currentContext == null) return;
@@ -566,20 +570,49 @@ class EmailActionReactor {
imagePaths,
key: '${actionType.name}_action',
category: actionType.category,
submenu: _getEmailActionSubmenu(
actionType: actionType,
imagePaths: imagePaths,
presentationEmail: presentationEmail,
labels: labels,
),
);
}).toList();
final submenuController = PopupSubmenuController();
final popupMenuWidget = PopupMenuActionGroupWidget(
actions: popupMenuItemEmailActions,
submenuController: submenuController,
onActionSelected: (action) {
handleEmailAction(presentationEmail, action.action);
},
);
openPopupMenu(currentContext!, position, popupMenuWidget);
openPopupMenu(
currentContext!,
position,
popupMenuWidget,
).whenComplete(submenuController.hide);
}
}
Widget? _getEmailActionSubmenu({
required EmailActionType actionType,
required ImagePaths imagePaths,
required PresentationEmail presentationEmail,
required List<Label>? labels,
}) {
if (actionType == EmailActionType.labelAs) {
return LabelListContextMenu(
labelList: labels ?? [],
presentationEmail: presentationEmail,
imagePaths: imagePaths,
);
}
return null;
}
bool _canDeletePermanently(
PresentationEmail email,
PresentationMailbox? mailboxContain,
@@ -0,0 +1,58 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/utils/theme_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:labels/extensions/label_extension.dart';
import 'package:labels/model/label.dart';
class LabelItemContextMenu extends StatelessWidget {
final Label label;
final ImagePaths imagePaths;
final bool isSelected;
const LabelItemContextMenu({
super.key,
required this.label,
required this.imagePaths,
required this.isSelected,
});
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () {},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
SvgPicture.asset(
isSelected
? imagePaths.icCheckboxSelected
: imagePaths.icCheckboxUnselected,
width: 20,
height: 20,
colorFilter: isSelected
? AppColor.primaryMain.asFilter()
: AppColor.steelGrayA540.asFilter(),
fit: BoxFit.fill,
),
const SizedBox(width: 16),
Expanded(
child: Text(
label.safeDisplayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ThemeUtils.textStyleBodyBody3(color: Colors.black),
),
)
],
),
),
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:labels/model/label.dart';
import 'package:model/email/presentation_email.dart';
import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart';
import 'package:tmail_ui_user/features/labels/presentation/widgets/label_item_context_menu.dart';
class LabelListContextMenu extends StatelessWidget {
final PresentationEmail presentationEmail;
final List<Label> labelList;
final ImagePaths imagePaths;
const LabelListContextMenu({
super.key,
required this.labelList,
required this.presentationEmail,
required this.imagePaths,
});
@override
Widget build(BuildContext context) {
final emailLabels = presentationEmail.getLabelList(labelList);
return ListView.builder(
shrinkWrap: true,
itemCount: labelList.length,
itemBuilder: (context, index) {
final label = labelList[index];
final isSelected = emailLabels.contains(label);
return LabelItemContextMenu(
label: label,
imagePaths: imagePaths,
isSelected: isSelected,
);
},
);
}
}
@@ -26,4 +26,7 @@ class PopupMenuItemMailboxAction
@override
Color get actionNameColor => action.getPopupMenuTitleColor();
@override
String get hoverIcon => imagePaths.icThumbsUp;
}
@@ -27,4 +27,7 @@ class PopupMenuItemProfileSettingTypeAction
@override
EdgeInsetsGeometry get itemPadding => const EdgeInsets.symmetric(horizontal: 16);
@override
String get hoverIcon => imagePaths.icThumbsUp;
}