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();
}
}