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 new file mode 100644 index 000000000..04eab5b5b --- /dev/null +++ b/lib/features/base/widget/popup_menu/popup_menu_action_group_widget.dart @@ -0,0 +1,53 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/base/extensions/popup_menu_action_list_extension.dart'; +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'; + +typedef OnPopupMenuActionSelected = void Function(PopupMenuItemAction action); + +class PopupMenuActionGroupWidget with PopupContextMenuActionMixin { + final List actions; + final OnPopupMenuActionSelected onActionSelected; + final double dividerOpacity; + + const PopupMenuActionGroupWidget({ + required this.actions, + required this.onActionSelected, + this.dividerOpacity = 0.12, + }); + + Future show( + BuildContext context, + RelativeRect position, + ) async { + final groupedActions = actions.groupByCategory(); + final entries = groupedActions.entries.toList(); + + final popupMenuItems = [ + for (var i = 0; i < entries.length; i++) ...[ + ...entries[i].value.map( + (menuAction) => PopupMenuItem( + key: menuAction.key != null ? Key(menuAction.key!) : null, + padding: EdgeInsets.zero, + child: PopupMenuItemActionWidget( + menuAction: menuAction, + menuActionClick: (menuAction) { + Navigator.pop(context); + onActionSelected(menuAction); + }, + ), + ), + ), + if (i < entries.length - 1) + PopupMenuDivider( + height: 1, + color: AppColor.gray424244.withValues(alpha: dividerOpacity), + ), + ], + ]; + + return openPopupMenuAction(context, position, popupMenuItems); + } +} diff --git a/lib/features/email/presentation/email_view.dart b/lib/features/email/presentation/email_view.dart index 941b3fef1..1e7126620 100644 --- a/lib/features/email/presentation/email_view.dart +++ b/lib/features/email/presentation/email_view.dart @@ -98,7 +98,7 @@ class EmailView extends GetWidget { additionalActions: [], emailIsRead: presentationEmail.hasRead, openBottomSheetContextMenu: controller.mailboxDashBoardController.openBottomSheetContextMenu, - openPopupMenu: controller.mailboxDashBoardController.openPopupMenu, + openPopupMenu: controller.mailboxDashBoardController.openPopupMenuActionGroup, ); }, supportBackAction: !isInsideThreadDetailView, @@ -283,7 +283,7 @@ class EmailView extends GetWidget { ], emailIsRead: presentationEmail.hasRead, openBottomSheetContextMenu: controller.mailboxDashBoardController.openBottomSheetContextMenu, - openPopupMenu: controller.mailboxDashBoardController.openPopupMenu, + openPopupMenu: controller.mailboxDashBoardController.openPopupMenuActionGroup, ), onToggleThreadDetailCollapseExpand: onToggleThreadDetailCollapseExpand, mailboxContain: presentationEmail.findMailboxContain( 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 b973d62d1..e3efd6950 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 @@ -29,10 +29,9 @@ import 'package:model/extensions/list_email_address_extension.dart'; import 'package:model/extensions/presentation_email_extension.dart'; import 'package:model/extensions/presentation_mailbox_extension.dart'; import 'package:model/mailbox/presentation_mailbox.dart'; -import 'package:tmail_ui_user/features/base/extensions/popup_menu_action_list_extension.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_item_action_widget.dart'; +import 'package:tmail_ui_user/features/base/widget/popup_menu/popup_menu_action_group_widget.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'; @@ -72,10 +71,10 @@ typedef OpenBottomSheetContextMenuAction = Future Function({ bool useGroupedActions, }); -typedef OpenPopUpContextMenuAction = Future Function( +typedef OpenPopupMenuActionGroup = Future Function( BuildContext context, RelativeRect position, - List popupMenuItems, + PopupMenuActionGroupWidget popupMenuWidget, ); class EmailActionReactor { @@ -485,7 +484,7 @@ class EmailActionReactor { required List additionalActions, required bool emailIsRead, required OpenBottomSheetContextMenuAction openBottomSheetContextMenu, - required OpenPopUpContextMenuAction openPopupMenu, + required OpenPopupMenuActionGroup openPopupMenu, }) { if (currentContext == null) return; @@ -557,17 +556,24 @@ class EmailActionReactor { useGroupedActions: true, ); } else { - openPopupMenu( - currentContext!, - position, - _popupMenuEmailActionTile( - currentContext!, - presentationEmail, - moreActions, + final popupMenuItemEmailActions = moreActions.map((actionType) { + return PopupMenuItemEmailAction( + actionType, + AppLocalizations.of(currentContext!), imagePaths, - handleEmailAction: handleEmailAction, - ) + key: '${actionType.name}_action', + category: actionType.category, + ); + }).toList(); + + final popupMenuWidget = PopupMenuActionGroupWidget( + actions: popupMenuItemEmailActions, + onActionSelected: (action) { + handleEmailAction(presentationEmail, action.action); + }, ); + + openPopupMenu(currentContext!, position, popupMenuWidget); } } @@ -582,53 +588,6 @@ class EmailActionReactor { ?? false; } - List _popupMenuEmailActionTile( - BuildContext context, - PresentationEmail presentationEmail, - List actionTypes, - ImagePaths imagePaths, { - required void Function( - PresentationEmail presentationEmail, - EmailActionType action, - ) handleEmailAction, - }) { - final popupMenuItemEmailActions = actionTypes.map((actionType) { - return PopupMenuItemEmailAction( - actionType, - AppLocalizations.of(context), - imagePaths, - key: '${actionType.name}_action', - category: actionType.category, - ); - }).toList(); - - final groupedActions = popupMenuItemEmailActions.groupByCategory(); - final entries = groupedActions.entries.toList(); - - final popupMenuItems = [ - for (var i = 0; i < entries.length; i++) ...[ - ...entries[i].value.map((menuAction) => PopupMenuItem( - key: menuAction.key != null ? Key(menuAction.key!) : null, - padding: EdgeInsets.zero, - child: PopupMenuItemActionWidget( - menuAction: menuAction, - menuActionClick: (menuAction) { - popBack(); - handleEmailAction(presentationEmail, menuAction.action); - }, - ), - )), - if (i < entries.length - 1) - PopupMenuDivider( - height: 1, - color: AppColor.gray424244.withValues(alpha: 0.12), - ), - ], - ]; - - return popupMenuItems; - } - Future openEmailAddressDialog( Session session, AccountId accountId, { diff --git a/lib/features/mailbox_dashboard/presentation/extensions/handle_open_context_menu_extension.dart b/lib/features/mailbox_dashboard/presentation/extensions/handle_open_context_menu_extension.dart index 74b0d04aa..e8b81c684 100644 --- a/lib/features/mailbox_dashboard/presentation/extensions/handle_open_context_menu_extension.dart +++ b/lib/features/mailbox_dashboard/presentation/extensions/handle_open_context_menu_extension.dart @@ -1,6 +1,7 @@ import 'package:core/utils/platform_info.dart'; import 'package:flutter/material.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/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; extension HandleOpenContextMenuExtension on MailboxDashBoardController { @@ -45,4 +46,22 @@ extension HandleOpenContextMenuExtension on MailboxDashBoardController { } }); } + + Future openPopupMenuActionGroup( + BuildContext context, + RelativeRect position, + PopupMenuActionGroupWidget popupMenuWidget, + ) { + if (PlatformInfo.isWeb) { + isPopupMenuOpened.value = true; + } + return popupMenuWidget.show( + context, + position, + ).whenComplete(() { + if (PlatformInfo.isWeb) { + isPopupMenuOpened.value = false; + } + }); + } } diff --git a/lib/features/search/email/presentation/extension/handle_email_more_action_extension.dart b/lib/features/search/email/presentation/extension/handle_email_more_action_extension.dart index cc71bc0da..6d2a55f3e 100644 --- a/lib/features/search/email/presentation/extension/handle_email_more_action_extension.dart +++ b/lib/features/search/email/presentation/extension/handle_email_more_action_extension.dart @@ -1,10 +1,8 @@ -import 'package:core/presentation/extensions/color_extension.dart'; import 'package:flutter/material.dart'; - import 'package:model/email/email_action_type.dart'; +import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; import 'package:model/extensions/presentation_mailbox_extension.dart'; -import 'package:tmail_ui_user/features/base/extensions/popup_menu_action_list_extension.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_menu_action_group_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/model/context_item_email_action.dart'; import 'package:tmail_ui_user/features/email/presentation/model/popup_menu_item_email_action.dart'; import 'package:tmail_ui_user/features/search/email/presentation/search_email_controller.dart'; @@ -68,36 +66,22 @@ extension HandleEmailMoreActionExtension on SearchEmailController { ); }).toList(); - final groupedActions = popupMenuItemEmailActions.groupByCategory(); - final entries = groupedActions.entries.toList(); + final popupMenuWidget = PopupMenuActionGroupWidget( + actions: popupMenuItemEmailActions, + onActionSelected: (action) { + pressEmailAction( + context, + action.action, + presentationEmail, + mailboxContain: mailboxContain, + ); + }, + ); - final popupMenuItems = [ - for (var i = 0; i < entries.length; i++) - ...[ - ...entries[i].value.map((menuAction) => PopupMenuItem( - padding: EdgeInsets.zero, - child: PopupMenuItemActionWidget( - menuAction: menuAction, - menuActionClick: (menuAction) { - popBack(); - pressEmailAction( - context, - menuAction.action, - presentationEmail, - mailboxContain: mailboxContain, - ); - }, - ), - )), - if (i < entries.length - 1) - PopupMenuDivider( - height: 1, - color: AppColor.gray424244.withValues(alpha: 0.12), - ), - ], - ]; - - return openPopupMenuAction(context, position, popupMenuItems); + return popupMenuWidget.show( + context, + position, + ); } } } diff --git a/lib/features/thread/presentation/thread_view.dart b/lib/features/thread/presentation/thread_view.dart index 88584c18e..1cbb3adc5 100644 --- a/lib/features/thread/presentation/thread_view.dart +++ b/lib/features/thread/presentation/thread_view.dart @@ -6,13 +6,12 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:model/model.dart'; -import 'package:tmail_ui_user/features/base/extensions/popup_menu_action_list_extension.dart'; 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/popup_menu/popup_menu_action_group_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'; import 'package:tmail_ui_user/features/email/presentation/model/context_item_email_action.dart'; @@ -678,37 +677,21 @@ class ThreadView extends GetWidget ); }).toList(); - final groupedActions = popupMenuItemEmailActions.groupByCategory(); - final entries = groupedActions.entries.toList(); + final popupMenuWidget = PopupMenuActionGroupWidget( + actions: popupMenuItemEmailActions, + onActionSelected: (action) { + controller.handleEmailActionType( + action.action, + presentationEmail, + mailboxContain: mailboxContain, + ); + }, + ); - final popupMenuItems = [ - for (var i = 0; i < entries.length; i++) ...[ - ...entries[i].value.map((menuAction) => PopupMenuItem( - padding: EdgeInsets.zero, - child: PopupMenuItemActionWidget( - menuAction: menuAction, - menuActionClick: (menuAction) { - popBack(); - controller.handleEmailActionType( - menuAction.action, - presentationEmail, - mailboxContain: mailboxContain, - ); - }, - ), - )), - if (i < entries.length - 1) - PopupMenuDivider( - height: 1, - color: AppColor.gray424244.withValues(alpha: 0.12), - ), - ], - ]; - - return controller.mailboxDashBoardController.openPopupMenuAction( + return controller.mailboxDashBoardController.openPopupMenuActionGroup( context, position, - popupMenuItems, + popupMenuWidget, ); } } diff --git a/lib/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart b/lib/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart index ed5b7e814..53a13b82b 100644 --- a/lib/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart +++ b/lib/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart @@ -1,4 +1,3 @@ -import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/utils/platform_info.dart'; import 'package:dartz/dartz.dart'; import 'package:flutter/material.dart'; @@ -7,8 +6,7 @@ import 'package:model/email/email_action_type.dart'; import 'package:model/email/mark_star_action.dart'; import 'package:model/email/read_actions.dart'; import 'package:model/mailbox/presentation_mailbox.dart'; -import 'package:tmail_ui_user/features/base/extensions/popup_menu_action_list_extension.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_menu_action_group_widget.dart'; import 'package:tmail_ui_user/features/destination_picker/presentation/model/destination_picker_arguments.dart'; import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; import 'package:tmail_ui_user/features/email/presentation/model/context_item_email_action.dart'; @@ -170,34 +168,17 @@ extension OnThreadDetailActionClick on ThreadDetailController { ); }).toList(); - final groupedActions = popupMenuItemEmailActions.groupByCategory(); - final entries = groupedActions.entries.toList(); + final popupMenuWidget = PopupMenuActionGroupWidget( + actions: popupMenuItemEmailActions, + onActionSelected: (action) { + onThreadDetailActionClick(action.action); + }, + ); - final popupMenuItems = [ - for (var i = 0; i < entries.length; i++) ...[ - ...entries[i].value.map((menuAction) => PopupMenuItem( - key: menuAction.key != null ? Key(menuAction.key!) : null, - padding: EdgeInsets.zero, - child: PopupMenuItemActionWidget( - menuAction: menuAction, - menuActionClick: (menuAction) { - popBack(); - onThreadDetailActionClick(menuAction.action); - }, - ), - )), - if (i < entries.length - 1) - PopupMenuDivider( - height: 1, - color: AppColor.gray424244.withValues(alpha: 0.12), - ), - ], - ]; - - mailboxDashBoardController.openPopupMenu( + mailboxDashBoardController.openPopupMenuActionGroup( currentContext!, position, - popupMenuItems, + popupMenuWidget, ); } } diff --git a/test/features/base/widgets/popup_menu_action_group_widget_test.dart b/test/features/base/widgets/popup_menu_action_group_widget_test.dart new file mode 100644 index 000000000..ec77d4f04 --- /dev/null +++ b/test/features/base/widgets/popup_menu_action_group_widget_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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_action_group_widget.dart'; + +/// Fake action for test +class FakePopupAction extends PopupMenuItemAction { + FakePopupAction(super.action, {super.key, super.category}); + + @override + String get actionName => action; +} + +void main() { + group('PopupMenuActionGroupWidget', () { + testWidgets( + 'should group actions correctly by category and render divider between groups', + (tester) async { + // Arrange + final actions = [ + FakePopupAction('Reply', category: 0), + FakePopupAction('Forward', category: 0), + FakePopupAction('Delete', category: 1), + FakePopupAction('Move', category: 2), + FakePopupAction('Archive', category: -1), + ]; + + bool tapped = false; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + child: const Text('Open Menu'), + onPressed: () { + PopupMenuActionGroupWidget( + actions: actions, + onActionSelected: (_) => tapped = true, + ).show( + context, + const RelativeRect.fromLTRB(50, 50, 0, 0), + ); + }, + ), + ), + ); + }, + ), + ), + ); + + // Act + await tester.tap(find.text('Open Menu')); + await tester.pumpAndSettle(); + + // Assert 1: Ensure all actions are displayed + expect(find.text('Reply'), findsOneWidget); + expect(find.text('Forward'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + expect(find.text('Move'), findsOneWidget); + expect(find.text('Archive'), findsOneWidget); + + // Assert 2: Check divider count (should be categoryCount - 1) + final dividers = find.byType(PopupMenuDivider); + expect(dividers, findsNWidgets(3)); // 4 groups → 3 dividers + + // Assert 3: Check display order (0 → 1 → 2 → -1) + final allTexts = tester + .widgetList(find.byType(Text)) + .map((t) => t.data) + .toList(); + final order = allTexts + .where( + (t) => [ + 'Reply', + 'Forward', + 'Delete', + 'Move', + 'Archive', + ].contains(t), + ) + .toList(); + expect(order, ['Reply', 'Forward', 'Delete', 'Move', 'Archive']); + + // Act: tap action and verify callback + await tester.tap(find.text('Reply')); + await tester.pumpAndSettle(); + expect(tapped, isTrue); + }); + + testWidgets('should show no dividers when only one category exists', + (tester) async { + // Arrange + final actions = [ + FakePopupAction('Mark Read', category: 0), + FakePopupAction('Mark Unread', category: 0), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + child: const Text('Open Menu'), + onPressed: () { + PopupMenuActionGroupWidget( + actions: actions, + onActionSelected: (_) {}, + ).show( + context, + const RelativeRect.fromLTRB(50, 50, 0, 0), + ); + }, + ), + ), + ); + }, + ), + ), + ); + + // Act + await tester.tap(find.text('Open Menu')); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('Mark Read'), findsOneWidget); + expect(find.text('Mark Unread'), findsOneWidget); + expect(find.byType(PopupMenuDivider), findsNothing); + }); + }); +}