diff --git a/core/lib/presentation/extensions/iterable_extension.dart b/core/lib/presentation/extensions/iterable_extension.dart new file mode 100644 index 000000000..e14b2a408 --- /dev/null +++ b/core/lib/presentation/extensions/iterable_extension.dart @@ -0,0 +1,44 @@ +extension IterableExtension on Iterable { + /// Group elements by [keySelector]. + /// If [sortKeys] = true, the keys will be sorted in ascending order, + /// key == -1 will always be at the bottom (applies to int keys). + Map> groupBy( + K Function(T element) keySelector, { + bool sortKeys = false, + }) { + final map = >{}; + for (final element in this) { + final key = keySelector(element); + map.putIfAbsent(key, () => []).add(element); + } + + Iterable sortedKeys; + + if (sortKeys && K == int) { + sortedKeys = map.keys.toList() + ..sort((a, b) { + final ai = a as int; + final bi = b as int; + if (ai == -1 && bi != -1) return 1; + if (bi == -1 && ai != -1) return -1; + return ai.compareTo(bi); + }); + } else if (sortKeys) { + sortedKeys = map.keys.toList() + ..sort((a, b) { + if (a is Comparable && b is Comparable) { + return (a as Comparable).compareTo(b); + } + return 0; + }); + } else { + sortedKeys = map.keys; + } + + final result = >{}; + for (final key in sortedKeys) { + result[key] = map[key]!; + } + return result; + } +} diff --git a/core/test/presentation/extensions/group_by_extension_test.dart b/core/test/presentation/extensions/group_by_extension_test.dart new file mode 100644 index 000000000..027964998 --- /dev/null +++ b/core/test/presentation/extensions/group_by_extension_test.dart @@ -0,0 +1,57 @@ +import 'package:core/presentation/extensions/iterable_extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('IterableExtension', () { + group('groupBy method', () { + test('group by int keys without sort', () { + final data = [1, 2, 3, 4]; + final result = data.groupBy((e) => e % 2); + + expect(result.keys.toList(), [1, 0]); + expect(result[0], [2, 4]); + expect(result[1], [1, 3]); + }); + + test('group by int keys with sort, -1 goes last', () { + final data = [10, 20, 30, 40]; + final categories = [2, -1, 1, 3]; + final result = data.groupBy((e) { + final idx = data.indexOf(e); + return categories[idx]; + }, sortKeys: true); + + expect(result.keys.toList(), [1, 2, 3, -1]); + expect(result[1], [30]); + expect(result[2], [10]); + expect(result[3], [40]); + expect(result[-1], [20]); + }); + + test('group by string keys with sort', () { + final words = ['apple', 'banana', 'cherry', 'avocado']; + final result = words.groupBy((w) => w[0], sortKeys: true); + + expect(result.keys.toList(), ['a', 'b', 'c']); + expect(result['a'], ['apple', 'avocado']); + expect(result['b'], ['banana']); + expect(result['c'], ['cherry']); + }); + + test('group with multiple elements in same group', () { + final data = ['cat', 'car', 'dog', 'door']; + final result = data.groupBy((s) => s[0]); + + expect(result['c'], ['cat', 'car']); + expect(result['d'], ['dog', 'door']); + }); + + test('empty list returns empty map', () { + final data = []; + final result = data.groupBy((e) => e); + + expect(result, isEmpty); + }); + }); + }); +} diff --git a/lib/features/base/extensions/context_menu_action_list_extension.dart b/lib/features/base/extensions/context_menu_action_list_extension.dart new file mode 100644 index 000000000..c2b06327f --- /dev/null +++ b/lib/features/base/extensions/context_menu_action_list_extension.dart @@ -0,0 +1,8 @@ +import 'package:core/presentation/extensions/iterable_extension.dart'; +import 'package:tmail_ui_user/features/base/widget/context_menu/context_menu_item_action.dart'; + +extension ContextMenuActionListExt on List { + Map> groupByCategory() { + return groupBy((action) => action.category, sortKeys: true); + } +} diff --git a/lib/features/base/mixin/popup_context_menu_action_mixin.dart b/lib/features/base/mixin/popup_context_menu_action_mixin.dart index 47be21e9f..6fd561daa 100644 --- a/lib/features/base/mixin/popup_context_menu_action_mixin.dart +++ b/lib/features/base/mixin/popup_context_menu_action_mixin.dart @@ -27,6 +27,7 @@ mixin PopupContextMenuActionMixin { required List itemActions, required OnContextMenuActionClick onContextMenuActionClick, Key? key, + bool useGroupedActions = false, }) async { return await showModalBottomSheet( context: context, @@ -50,6 +51,7 @@ mixin PopupContextMenuActionMixin { child: ContextMenuDialogView( actions: itemActions, onContextMenuActionClick: onContextMenuActionClick, + useGroupedActions: useGroupedActions, ), ), ); diff --git a/lib/features/base/widget/context_menu/context_menu_dialog_view.dart b/lib/features/base/widget/context_menu/context_menu_dialog_view.dart index e49c32f90..599d093eb 100644 --- a/lib/features/base/widget/context_menu/context_menu_dialog_view.dart +++ b/lib/features/base/widget/context_menu/context_menu_dialog_view.dart @@ -1,27 +1,60 @@ +import 'package:core/presentation/extensions/color_extension.dart'; import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/base/extensions/context_menu_action_list_extension.dart'; import 'package:tmail_ui_user/features/base/widget/context_menu/context_menu_dialog_item.dart'; import 'package:tmail_ui_user/features/base/widget/context_menu/context_menu_item_action.dart'; class ContextMenuDialogView extends StatelessWidget { final List actions; final OnContextMenuActionClick onContextMenuActionClick; + final bool useGroupedActions; const ContextMenuDialogView({ super.key, required this.actions, required this.onContextMenuActionClick, + this.useGroupedActions = false, }); @override Widget build(BuildContext context) { + List childrenWidget = []; + + if (useGroupedActions) { + final groupedActions = actions.groupByCategory(); + final entries = groupedActions.entries.toList(); + childrenWidget = [ + for (var i = 0; i < entries.length; i++) + ...[ + ...entries[i].value.map((menuAction) => ContextMenuDialogItem( + key: menuAction.key != null ? Key(menuAction.key!) : null, + menuAction: menuAction, + onContextMenuActionClick: onContextMenuActionClick, + )), + if (i < entries.length - 1) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Divider( + height: 1, + color: AppColor.gray424244.withValues(alpha: 0.12), + ), + ), + ], + ]; + } else { + childrenWidget = actions + .map((menuAction) => ContextMenuDialogItem( + key: menuAction.key != null ? Key(menuAction.key!) : null, + menuAction: menuAction, + onContextMenuActionClick: onContextMenuActionClick, + )) + .toList(); + } + return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, - children: actions.map((menuAction) => ContextMenuDialogItem( - key: menuAction.key != null ? Key(menuAction.key!) : null, - menuAction: menuAction, - onContextMenuActionClick: onContextMenuActionClick, - )).toList(), + children: childrenWidget, ), ); } diff --git a/lib/features/base/widget/context_menu/context_menu_item_action.dart b/lib/features/base/widget/context_menu/context_menu_item_action.dart index 45274b699..4e3c06972 100644 --- a/lib/features/base/widget/context_menu/context_menu_item_action.dart +++ b/lib/features/base/widget/context_menu/context_menu_item_action.dart @@ -7,11 +7,12 @@ typedef OnContextMenuActionClick = void Function(ContextMenuItemAction action); abstract class ContextMenuItemAction with EquatableMixin { final T action; final String? key; + final int category; - ContextMenuItemAction(this.action, {this.key}); + ContextMenuItemAction(this.action, {this.key, this.category = -1}); @override - List get props => [action, key]; + List get props => [action, key, category]; String get actionName; @@ -34,7 +35,7 @@ mixin OptionalSelectedIcon { abstract class ContextMenuItemActionRequiredIcon extends ContextMenuItemAction with OptionalIcon { - ContextMenuItemActionRequiredIcon(super.action, {super.key}); + ContextMenuItemActionRequiredIcon(super.action, {super.key, super.category}); } abstract class ContextMenuItemActionRequiredSelectedIcon @@ -42,12 +43,17 @@ abstract class ContextMenuItemActionRequiredSelectedIcon final T? selectedAction; - ContextMenuItemActionRequiredSelectedIcon(super.action, this.selectedAction, {super.key}); + ContextMenuItemActionRequiredSelectedIcon( + super.action, + this.selectedAction, { + super.key, + super.category, + }); } abstract class ContextMenuItemActionRequiredFull extends ContextMenuItemAction with OptionalIcon, OptionalSelectedIcon { final T selectedAction; - ContextMenuItemActionRequiredFull(super.action, this.selectedAction, {super.key}); + ContextMenuItemActionRequiredFull(super.action, this.selectedAction, {super.key, super.category,}); } \ No newline at end of file diff --git a/lib/features/email/presentation/model/context_item_email_action.dart b/lib/features/email/presentation/model/context_item_email_action.dart index eb75ab3cb..8b0f97845 100644 --- a/lib/features/email/presentation/model/context_item_email_action.dart +++ b/lib/features/email/presentation/model/context_item_email_action.dart @@ -15,6 +15,7 @@ class ContextItemEmailAction this.appLocalizations, this.imagePaths, { super.key, + super.category, }); @override 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 315e99c6c..e0671a59f 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 @@ -77,6 +77,7 @@ typedef OpenBottomSheetContextMenuAction = Future Function({ required List itemActions, required OnContextMenuActionClick onContextMenuActionClick, Key? key, + bool useGroupedActions, }); typedef OpenPopUpContextMenuAction = Future Function( @@ -592,6 +593,7 @@ class EmailActionReactor { AppLocalizations.of(currentContext!), imagePaths, key: '${action.name}_action', + category: action.category, ), ) .toList(), @@ -599,6 +601,7 @@ class EmailActionReactor { popBack(); handleEmailAction(presentationEmail, action.action); }, + useGroupedActions: true, ); } else { openPopupMenu( 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 2f38601fa..74b0d04aa 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 @@ -9,6 +9,7 @@ extension HandleOpenContextMenuExtension on MailboxDashBoardController { required List itemActions, required OnContextMenuActionClick onContextMenuActionClick, Key? key, + bool useGroupedActions = false, }) { if (PlatformInfo.isWeb) { isContextMenuOpened.value = true; @@ -18,6 +19,7 @@ extension HandleOpenContextMenuExtension on MailboxDashBoardController { itemActions: itemActions, onContextMenuActionClick: onContextMenuActionClick, key: key, + useGroupedActions: useGroupedActions, ).whenComplete(() { if (PlatformInfo.isWeb) { isContextMenuOpened.value = false; 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 9bd012293..90181e433 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 @@ -145,6 +145,7 @@ extension OnThreadDetailActionClick on ThreadDetailController { action, AppLocalizations.of(currentContext!), imagePaths, + category: action.category, ), ) .toList(), @@ -152,6 +153,7 @@ extension OnThreadDetailActionClick on ThreadDetailController { popBack(); onThreadDetailActionClick(action.action); }, + useGroupedActions: true, ); } else { mailboxDashBoardController.openPopupMenu(