diff --git a/assets/images/ic_thumbs_up.svg b/assets/images/ic_thumbs_up.svg new file mode 100644 index 0000000000..6783c7c53f --- /dev/null +++ b/assets/images/ic_thumbs_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index ce676362a1..798ae8d4e6 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -263,6 +263,7 @@ class ImagePaths { String get icUser => _getImagePath('ic_user.svg'); String get icTag => _getImagePath('ic_tag.svg'); String get icColorPicker => _getImagePath('ic_color_picker.svg'); + String get icThumbsUp => _getImagePath('ic_thumbs_up.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/labels/lib/converter/keyword_identifier_nullable_converter.dart b/labels/lib/converter/keyword_identifier_nullable_converter.dart new file mode 100644 index 0000000000..7c86f9f6b2 --- /dev/null +++ b/labels/lib/converter/keyword_identifier_nullable_converter.dart @@ -0,0 +1,14 @@ +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:json_annotation/json_annotation.dart'; + +class KeywordIdentifierNullableConverter + implements JsonConverter { + const KeywordIdentifierNullableConverter(); + + @override + KeyWordIdentifier? fromJson(String? json) => + json != null ? KeyWordIdentifier(json) : null; + + @override + String? toJson(KeyWordIdentifier? object) => object?.value; +} diff --git a/labels/lib/extensions/label_extension.dart b/labels/lib/extensions/label_extension.dart index 91becde419..49e3c4a7c0 100644 --- a/labels/lib/extensions/label_extension.dart +++ b/labels/lib/extensions/label_extension.dart @@ -2,6 +2,7 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/extensions/hex_color_extension.dart'; import 'package:flutter/material.dart'; import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:labels/model/hex_color.dart'; import 'package:labels/model/label.dart'; @@ -28,7 +29,7 @@ extension LabelExtension on Label { Label copyWith({ Id? id, - String? keyword, + KeyWordIdentifier? keyword, String? displayName, HexColor? color, }) { diff --git a/labels/lib/model/label.dart b/labels/lib/model/label.dart index 3189cdf199..6eed79996d 100644 --- a/labels/lib/model/label.dart +++ b/labels/lib/model/label.dart @@ -1,8 +1,10 @@ import 'package:equatable/equatable.dart'; import 'package:jmap_dart_client/http/converter/id_converter.dart'; import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:labels/converter/hex_color_nullable_converter.dart'; +import 'package:labels/converter/keyword_identifier_nullable_converter.dart'; import 'package:labels/model/hex_color.dart'; part 'label.g.dart'; @@ -13,11 +15,12 @@ part 'label.g.dart'; converters: [ IdConverter(), HexColorNullableConverter(), + KeywordIdentifierNullableConverter(), ], ) class Label with EquatableMixin { final Id? id; - final String? keyword; + final KeyWordIdentifier? keyword; final String? displayName; final HexColor? color; diff --git a/labels/test/method/get/get_label_method_test.dart b/labels/test/method/get/get_label_method_test.dart index c9f36b6032..02e65d36aa 100644 --- a/labels/test/method/get/get_label_method_test.dart +++ b/labels/test/method/get/get_label_method_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:labels/labels.dart'; import '../method_fixtures.dart'; @@ -41,13 +42,13 @@ void main() { final labelA = Label( id: Id('A'), - keyword: 'labelA', + keyword: KeyWordIdentifier('labelA'), displayName: 'Label A', color: HexColor('#111111'), ); final labelB = Label( id: Id('B'), - keyword: 'labelB', + keyword: KeyWordIdentifier('labelB'), displayName: 'Label B', color: HexColor('#222222'), ); @@ -205,7 +206,7 @@ void main() { expect(parsed.notFound, isEmpty); }); - test('should throw DioException when server returns 500', () async { + test('should throw DioError when server returns 500', () async { // Arrange final dio = createDio(); final adapter = DioAdapter(dio: dio); diff --git a/labels/test/method/set/set_label_method_test.dart b/labels/test/method/set/set_label_method_test.dart index 1b8e59eb5f..54c9dd2df3 100644 --- a/labels/test/method/set/set_label_method_test.dart +++ b/labels/test/method/set/set_label_method_test.dart @@ -86,7 +86,7 @@ void main() { // Assert expect(parsed, isNotNull); - expect(parsed!.created![Id('4f29')]!.keyword, equals('important')); + expect(parsed!.created![Id('4f29')]!.keyword?.value, equals('important')); }); test('should process multiple created labels', () async { @@ -143,8 +143,8 @@ void main() { // Assert expect(parsed, isNotNull); expect(parsed!.created!.length, equals(2)); - expect(parsed.created![Id('A')]!.keyword, equals('tagA')); - expect(parsed.created![Id('B')]!.keyword, equals('tagB')); + expect(parsed.created![Id('A')]!.keyword?.value, equals('tagA')); + expect(parsed.created![Id('B')]!.keyword?.value, equals('tagB')); }); test('should throw DioException when backend returns 500', () async { diff --git a/lib/features/base/base_mailbox_controller.dart b/lib/features/base/base_mailbox_controller.dart index 6b108457cc..df37cc96a9 100644 --- a/lib/features/base/base_mailbox_controller.dart +++ b/lib/features/base/base_mailbox_controller.dart @@ -389,7 +389,7 @@ abstract class BaseMailboxController extends BaseController ); final destinationMailbox = PlatformInfo.isWeb - ? await DialogRouter.pushGeneralDialog(routeName: AppRoutes.destinationPicker, arguments: arguments) + ? await DialogRouter().pushGeneralDialog(routeName: AppRoutes.destinationPicker, arguments: arguments) : await push(AppRoutes.destinationPicker, arguments: arguments); if (destinationMailbox is PresentationMailbox) { @@ -658,7 +658,7 @@ abstract class BaseMailboxController extends BaseController ); final destinationMailbox = PlatformInfo.isWeb - ? await DialogRouter.pushGeneralDialog( + ? await DialogRouter().pushGeneralDialog( routeName: AppRoutes.destinationPicker, arguments: arguments, ) diff --git a/lib/features/base/model/popup_menu_item_action.dart b/lib/features/base/model/popup_menu_item_action.dart index 1cbffb5ba0..9e53516689 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/hover_submenu_controller.dart b/lib/features/base/widget/popup_menu/hover_submenu_controller.dart new file mode 100644 index 0000000000..07a9a71d8e --- /dev/null +++ b/lib/features/base/widget/popup_menu/hover_submenu_controller.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +class HoverSubmenuController { + HoverSubmenuController({ + this.exitDelay = const Duration(milliseconds: 120), + }); + + final Duration exitDelay; + + final ValueNotifier isHovering = ValueNotifier(false); + + int _hoverRefCount = 0; + Timer? _exitTimer; + + void enter() { + _exitTimer?.cancel(); + _hoverRefCount++; + if (!isHovering.value) { + isHovering.value = true; + } + } + + void exit() { + _hoverRefCount = (_hoverRefCount - 1).clamp(0, 999); + + if (_hoverRefCount == 0) { + _exitTimer?.cancel(); + _exitTimer = Timer(exitDelay, () { + if (_hoverRefCount == 0) { + isHovering.value = false; + } + }); + } + } + + void dispose() { + _exitTimer?.cancel(); + isHovering.dispose(); + } +} 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 04eab5b5b2..d097909273 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( @@ -34,9 +37,19 @@ class PopupMenuActionGroupWidget with PopupContextMenuActionMixin { child: PopupMenuItemActionWidget( menuAction: menuAction, menuActionClick: (menuAction) { + submenuController?.hide(); 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, ), ), ), @@ -48,6 +61,30 @@ class PopupMenuActionGroupWidget with PopupContextMenuActionMixin { ], ]; - return openPopupMenuAction(context, position, popupMenuItems); + try { + await openPopupMenuAction(context, position, popupMenuItems); + } finally { + submenuController?.hide(); + } + } + + void _showPopupSubmenu({ + required BuildContext context, + required GlobalKey itemKey, + required PopupSubmenuController submenuController, + required Widget submenu, +}) { + final renderObject = itemKey.currentContext?.findRenderObject(); + if (renderObject is! RenderBox) return; + final renderBox = renderObject; + + 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 281534d1d8..38078e13e2 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,54 @@ 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/base/widget/popup_menu/hover_submenu_controller.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; + HoverSubmenuController? _hoverController; + + @override + void initState() { + super.initState(); + if (widget.menuAction is PopupMenuItemEmailAction && + widget.menuAction.action == EmailActionType.labelAs) { + _itemKey = GlobalKey(); + _hoverController = HoverSubmenuController(); + } + } + @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 +60,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,9 +73,11 @@ class PopupMenuItemActionWidget extends StatelessWidget { fit: BoxFit.fill, ), ); - isSelected = specificMenuAction.selectedAction == menuAction.action; - } else if (menuAction is PopupMenuItemActionRequiredFull) { - final specificMenuAction = menuAction as PopupMenuItemActionRequiredFull; + isSelected = + specificMenuAction.selectedAction == widget.menuAction.action; + } else if (widget.menuAction is PopupMenuItemActionRequiredFull) { + final specificMenuAction = + widget.menuAction as PopupMenuItemActionRequiredFull; iconWidget = Padding( padding: const EdgeInsetsDirectional.only(end: 16), child: SvgPicture.asset( @@ -69,11 +98,12 @@ class PopupMenuItemActionWidget extends StatelessWidget { fit: BoxFit.fill, ), ); - isSelected = specificMenuAction.selectedAction == menuAction.action; - } else if (menuAction + isSelected = + specificMenuAction.selectedAction == widget.menuAction.action; + } else if (widget.menuAction is PopupMenuItemActionRequiredIconWithMultipleSelected) { - final specificMenuAction = - menuAction as PopupMenuItemActionRequiredIconWithMultipleSelected; + final specificMenuAction = widget.menuAction + as PopupMenuItemActionRequiredIconWithMultipleSelected; iconWidget = Padding( padding: const EdgeInsetsDirectional.only(end: 16), child: SvgPicture.asset( @@ -95,27 +125,103 @@ class PopupMenuItemActionWidget extends StatelessWidget { ), ); isSelected = - specificMenuAction.selectedActions.contains(menuAction.action); + 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: (_) { + _hoverController?.enter(); + + if (_itemKey != null) { + widget.onHoverShowSubmenu?.call(_itemKey!); + } else { + widget.onHoverOtherItem?.call(); + } + }, + onExit: (_) { + _hoverController?.exit(); + }, + 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 (_hoverController != null) + ValueListenableBuilder( + valueListenable: _hoverController!.isHovering, + builder: (_, isHovering, __) { + if (!isHovering) return const SizedBox.shrink(); + 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, + ), + ); + }, + ) + ], + ), + ), + ), + ), + ), + ); } return PointerInterceptor( child: Material( type: MaterialType.transparency, child: InkWell( - onTap: () => menuAction.onClick(menuActionClick), + onTap: () => widget.menuAction.onClick(widget.menuActionClick), hoverColor: AppColor.popupMenuItemHovered, + onHover: (_) { + _hoverController?.exit(); + 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 +236,13 @@ class PopupMenuItemActionWidget extends StatelessWidget { ), ); } + + @override + void dispose() { + if (_hoverController != null) { + _hoverController?.dispose(); + _hoverController = 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 0000000000..f22df85c4c --- /dev/null +++ b/lib/features/base/widget/popup_menu/popup_submenu_controller.dart @@ -0,0 +1,93 @@ +import 'dart:math' as math; + +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 overlayState = Overlay.maybeOf(context, rootOverlay: true); + if (overlayState == null) return; + + final mediaSize = MediaQuery.sizeOf(context); + final screenWidth = mediaSize.width; + final screenHeight = mediaSize.height; + + 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; + } + } + + final clampedLeft = finalLeft + .clamp(0.0, math.max(0.0, screenWidth - submenuWidth)) + .toDouble(); + final availableHeight = math.max(0.0, screenHeight - anchor.top); + final finalHeight = math.min(submenuMaxHeight, availableHeight); + + _submenuEntry = OverlayEntry( + builder: (_) { + return PositionedDirectional( + start: clampedLeft, + top: anchor.top, + child: MouseRegion( + onExit: (_) => hide(), + child: Material( + elevation: 8, + color: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + child: SizedBox( + width: submenuWidth, + height: finalHeight, + child: submenu, + ), + ), + ), + ); + }, + ); + + overlayState.insert(_submenuEntry!); + } + + void hide() { + _submenuEntry?.remove(); + _submenuEntry = null; + } + + void dispose() { + hide(); + } +} diff --git a/lib/features/composer/presentation/composer_view_web.dart b/lib/features/composer/presentation/composer_view_web.dart index 7277ccda10..04ef3bab02 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -53,12 +53,14 @@ class ComposerView extends GetWidget { @override Widget build(BuildContext context) { final iframeOverlay = Obx(() { + final dialogRouter = DialogRouter(); + bool isOverlayEnabled = controller.mailboxDashBoardController.isDisplayedOverlayViewOnIFrame || MessageDialogActionManager().isDialogOpened || EmailActionReactor.isDialogOpened || ColorDialogPicker().isOpened.isTrue || - DialogRouter.isRuleFilterDialogOpened.isTrue || - DialogRouter.isDialogOpened; + dialogRouter.isRuleFilterDialogOpened.isTrue || + dialogRouter.isDialogOpened; if (isOverlayEnabled) { return Positioned.fill( diff --git a/lib/features/composer/presentation/extensions/email_action_type_extension.dart b/lib/features/composer/presentation/extensions/email_action_type_extension.dart index 99263639d0..0a1ba74145 100644 --- a/lib/features/composer/presentation/extensions/email_action_type_extension.dart +++ b/lib/features/composer/presentation/extensions/email_action_type_extension.dart @@ -175,6 +175,8 @@ extension EmailActionTypeExtension on EmailActionType { case EmailActionType.moveToTrash: case EmailActionType.deletePermanently: return imagePaths.icDeleteComposer; + case EmailActionType.labelAs: + return imagePaths.icTag; default: return ''; } @@ -222,6 +224,8 @@ extension EmailActionTypeExtension on EmailActionType { return appLocalizations.move_to_trash; case EmailActionType.deletePermanently: return appLocalizations.delete_permanently; + case EmailActionType.labelAs: + return appLocalizations.labelAs; default: return ''; } diff --git a/lib/features/email/data/datasource/email_datasource.dart b/lib/features/email/data/datasource/email_datasource.dart index dec9c104c0..c305a3318f 100644 --- a/lib/features/email/data/datasource/email_datasource.dart +++ b/lib/features/email/data/datasource/email_datasource.dart @@ -11,6 +11,7 @@ import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/user_name.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/account/account_request.dart'; import 'package:model/email/attachment.dart'; import 'package:model/email/mark_star_action.dart'; @@ -203,4 +204,11 @@ abstract class EmailDataSource { ); Future generateEntireMessageAsDocument(ViewEntireMessageRequest entireMessageRequest); + + Future addLabelToEmail( + Session session, + AccountId accountId, + EmailId emailId, + KeyWordIdentifier labelKeyword, + ); } \ No newline at end of file diff --git a/lib/features/email/data/datasource_impl/email_datasource_impl.dart b/lib/features/email/data/datasource_impl/email_datasource_impl.dart index d48546f689..9cda5cad19 100644 --- a/lib/features/email/data/datasource_impl/email_datasource_impl.dart +++ b/lib/features/email/data/datasource_impl/email_datasource_impl.dart @@ -22,6 +22,7 @@ import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/user_name.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/model.dart'; import 'package:tmail_ui_user/features/composer/domain/model/email_request.dart'; import 'package:tmail_ui_user/features/email/data/datasource/email_datasource.dart'; @@ -552,4 +553,21 @@ class EmailDataSourceImpl extends EmailDataSource { Future generateEntireMessageAsDocument(ViewEntireMessageRequest entireMessageRequest) { throw UnimplementedError(); } + + @override + Future addLabelToEmail( + Session session, + AccountId accountId, + EmailId emailId, + KeyWordIdentifier labelKeyword, + ) { + return Future.sync(() async { + return await emailAPI.addLabelToEmail( + session, + accountId, + emailId, + labelKeyword, + ); + }).catchError(_exceptionThrower.throwException); + } } \ No newline at end of file diff --git a/lib/features/email/data/datasource_impl/email_hive_cache_datasource_impl.dart b/lib/features/email/data/datasource_impl/email_hive_cache_datasource_impl.dart index 4297ac76d7..1ffdecac2f 100644 --- a/lib/features/email/data/datasource_impl/email_hive_cache_datasource_impl.dart +++ b/lib/features/email/data/datasource_impl/email_hive_cache_datasource_impl.dart @@ -575,4 +575,9 @@ class EmailHiveCacheDataSourceImpl extends EmailDataSource { Future generateEntireMessageAsDocument(ViewEntireMessageRequest entireMessageRequest) { throw UnimplementedError(); } + + @override + Future addLabelToEmail(Session session, AccountId accountId, EmailId emailId, KeyWordIdentifier labelKeyword) { + throw UnimplementedError(); + } } \ No newline at end of file diff --git a/lib/features/email/data/datasource_impl/email_local_storage_datasource_impl.dart b/lib/features/email/data/datasource_impl/email_local_storage_datasource_impl.dart index e32b5abc15..2038731d3e 100644 --- a/lib/features/email/data/datasource_impl/email_local_storage_datasource_impl.dart +++ b/lib/features/email/data/datasource_impl/email_local_storage_datasource_impl.dart @@ -21,6 +21,7 @@ import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/user_name.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/account/account_request.dart'; import 'package:model/email/attachment.dart'; import 'package:model/email/mark_star_action.dart'; @@ -350,4 +351,9 @@ class EmailLocalStorageDataSourceImpl extends EmailDataSource { }) { throw UnimplementedError(); } + + @override + Future addLabelToEmail(Session session, AccountId accountId, EmailId emailId, KeyWordIdentifier labelKeyword) { + throw UnimplementedError(); + } } \ No newline at end of file diff --git a/lib/features/email/data/datasource_impl/email_session_storage_datasource_impl.dart b/lib/features/email/data/datasource_impl/email_session_storage_datasource_impl.dart index 1c6030111a..396f4ed76a 100644 --- a/lib/features/email/data/datasource_impl/email_session_storage_datasource_impl.dart +++ b/lib/features/email/data/datasource_impl/email_session_storage_datasource_impl.dart @@ -13,6 +13,7 @@ import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/user_name.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/account/account_request.dart'; import 'package:model/email/attachment.dart'; import 'package:model/email/mark_star_action.dart'; @@ -264,4 +265,9 @@ class EmailSessionStorageDatasourceImpl extends EmailDataSource { }) { throw UnimplementedError(); } + + @override + Future addLabelToEmail(Session session, AccountId accountId, EmailId emailId, KeyWordIdentifier labelKeyword) { + throw UnimplementedError(); + } } \ No newline at end of file diff --git a/lib/features/email/data/network/email_api.dart b/lib/features/email/data/network/email_api.dart index 9a016857f2..6d3b78499c 100644 --- a/lib/features/email/data/network/email_api.dart +++ b/lib/features/email/data/network/email_api.dart @@ -936,4 +936,32 @@ class EmailAPI with HandleSetErrorMixin, MailAPIMixin { throw NotParsableBlobIdToEmailException(); } } + + Future addLabelToEmail( + Session session, + AccountId accountId, + EmailId emailId, + KeyWordIdentifier labelKeyword, + ) async { + final method = SetEmailMethod(accountId) + ..addUpdates({emailId.id: labelKeyword.generateLabelActionPath()}); + + final builder = JmapRequestBuilder(_httpClient, ProcessingInvocation()); + final invocation = builder.invocation(method); + + final capabilities = method.requiredCapabilities + .toCapabilitiesSupportTeamMailboxes(session, accountId); + final result = await (builder..usings(capabilities)).build().execute(); + + final response = result.parse( + invocation.methodCallId, + SetEmailResponse.deserialize, + ); + + final emailIdsUpdated = response?.updated?.keys ?? []; + + if (emailIdsUpdated.isEmpty || !emailIdsUpdated.contains(emailId.id)) { + throw parseErrorForSetResponse(response, emailId.id); + } + } } \ No newline at end of file diff --git a/lib/features/email/data/repository/email_repository_impl.dart b/lib/features/email/data/repository/email_repository_impl.dart index ed99bedad0..7c7f0f6658 100644 --- a/lib/features/email/data/repository/email_repository_impl.dart +++ b/lib/features/email/data/repository/email_repository_impl.dart @@ -13,6 +13,7 @@ import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/email/email_content.dart'; import 'package:model/email/mark_star_action.dart'; import 'package:model/email/read_actions.dart'; @@ -479,4 +480,19 @@ class EmailRepositoryImpl extends EmailRepository { Future generateEntireMessageAsDocument(ViewEntireMessageRequest entireMessageRequest) { return emailDataSource[DataSourceType.local]!.generateEntireMessageAsDocument(entireMessageRequest); } + + @override + Future addLabelToEmail( + Session session, + AccountId accountId, + EmailId emailId, + KeyWordIdentifier labelKeyword, + ) { + return emailDataSource[DataSourceType.network]!.addLabelToEmail( + session, + accountId, + emailId, + labelKeyword, + ); + } } \ No newline at end of file diff --git a/lib/features/email/domain/repository/email_repository.dart b/lib/features/email/domain/repository/email_repository.dart index 2407cdb1c5..552dcb81d3 100644 --- a/lib/features/email/domain/repository/email_repository.dart +++ b/lib/features/email/domain/repository/email_repository.dart @@ -11,6 +11,7 @@ import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/email/email_content.dart'; import 'package:model/email/mark_star_action.dart'; import 'package:model/email/read_actions.dart'; @@ -160,4 +161,11 @@ abstract class EmailRepository { Future printEmail(EmailPrint emailPrint); Future generateEntireMessageAsDocument(ViewEntireMessageRequest entireMessageRequest); + + Future addLabelToEmail( + Session session, + AccountId accountId, + EmailId emailId, + KeyWordIdentifier labelKeyword, + ); } \ No newline at end of file diff --git a/lib/features/email/domain/state/add_a_label_to_an_email_state.dart b/lib/features/email/domain/state/add_a_label_to_an_email_state.dart new file mode 100644 index 0000000000..6ca6df6826 --- /dev/null +++ b/lib/features/email/domain/state/add_a_label_to_an_email_state.dart @@ -0,0 +1,29 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; + +class AddingALabelToAnEmail extends LoadingState {} + +class AddALabelToAnEmailSuccess extends UIState { + final EmailId emailId; + final KeyWordIdentifier labelKeyword; + final String labelDisplay; + + AddALabelToAnEmailSuccess(this.emailId, this.labelKeyword, this.labelDisplay); + + @override + List get props => [emailId, labelKeyword, labelDisplay]; +} + +class AddALabelToAnEmailFailure extends FeatureFailure { + final String labelDisplay; + + AddALabelToAnEmailFailure({ + dynamic exception, + required this.labelDisplay, + }) : super(exception: exception); + + @override + List get props => [...super.props, labelDisplay]; +} diff --git a/lib/features/email/domain/usecases/add_a_label_to_an_email_interactor.dart b/lib/features/email/domain/usecases/add_a_label_to_an_email_interactor.dart new file mode 100644 index 0000000000..4c2d8a0b2d --- /dev/null +++ b/lib/features/email/domain/usecases/add_a_label_to_an_email_interactor.dart @@ -0,0 +1,43 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +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/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/email/domain/repository/email_repository.dart'; +import 'package:tmail_ui_user/features/email/domain/state/add_a_label_to_an_email_state.dart'; + +class AddALabelToAnEmailInteractor { + final EmailRepository _emailRepository; + + AddALabelToAnEmailInteractor(this._emailRepository); + + Stream> execute( + Session session, + AccountId accountId, + EmailId emailId, + KeyWordIdentifier labelKeyword, + String labelDisplay, + ) async* { + try { + yield Right(AddingALabelToAnEmail()); + await _emailRepository.addLabelToEmail( + session, + accountId, + emailId, + labelKeyword, + ); + yield Right(AddALabelToAnEmailSuccess( + emailId, + labelKeyword, + labelDisplay, + )); + } catch (e) { + yield Left(AddALabelToAnEmailFailure( + exception: e, + labelDisplay: labelDisplay, + )); + } + } +} diff --git a/lib/features/email/presentation/bindings/email_bindings.dart b/lib/features/email/presentation/bindings/email_bindings.dart index 76d48df767..561f7ec1a2 100644 --- a/lib/features/email/presentation/bindings/email_bindings.dart +++ b/lib/features/email/presentation/bindings/email_bindings.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/add_a_label_to_an_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_email_content_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_email_read_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_interactor.dart'; @@ -23,6 +24,7 @@ class EmailBindings extends Bindings { Get.find(), Get.find(), Get.find(), + Get.find(), Get.find(), currentEmailId: currentEmailId, ), tag: tag); diff --git a/lib/features/email/presentation/bindings/email_interactor_bindings.dart b/lib/features/email/presentation/bindings/email_interactor_bindings.dart index e689a5e744..9e4e3a543d 100644 --- a/lib/features/email/presentation/bindings/email_interactor_bindings.dart +++ b/lib/features/email/presentation/bindings/email_interactor_bindings.dart @@ -16,6 +16,7 @@ import 'package:tmail_ui_user/features/email/data/local/html_analyzer.dart'; import 'package:tmail_ui_user/features/email/data/network/email_api.dart'; import 'package:tmail_ui_user/features/email/data/repository/email_repository_impl.dart'; import 'package:tmail_ui_user/features/email/domain/repository/email_repository.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/add_a_label_to_an_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_email_content_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_entire_message_as_document_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_stored_email_state_interactor.dart'; @@ -117,6 +118,9 @@ class EmailInteractorBindings extends InteractorsBindings { Get.find(), )); } + Get.lazyPut( + () => AddALabelToAnEmailInteractor(Get.find()), + ); } @override diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 0dc87d033e..e5bc91eb02 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -35,6 +35,7 @@ import 'package:tmail_ui_user/features/email/domain/model/event_action.dart'; import 'package:tmail_ui_user/features/email/domain/model/mark_read_action.dart'; import 'package:tmail_ui_user/features/email/domain/model/send_receipt_to_sender_request.dart'; import 'package:tmail_ui_user/features/email/domain/model/view_entire_message_request.dart'; +import 'package:tmail_ui_user/features/email/domain/state/add_a_label_to_an_email_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/calendar_event_accept_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/calendar_event_counter_accept_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/calendar_event_maybe_state.dart'; @@ -48,6 +49,7 @@ import 'package:tmail_ui_user/features/email/domain/state/parse_calendar_event_s import 'package:tmail_ui_user/features/email/domain/state/print_email_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/send_receipt_to_sender_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/unsubscribe_email_state.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/add_a_label_to_an_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/calendar_event_accept_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/calendar_event_counter_accept_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/calendar_event_reject_interactor.dart'; @@ -65,6 +67,7 @@ import 'package:tmail_ui_user/features/email/presentation/bindings/calendar_even import 'package:tmail_ui_user/features/email/presentation/bindings/mdn_interactor_bindings.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_attendee_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_organizer_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/handle_label_for_email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/handle_mail_action_by_shortcut_action_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/handle_open_attachment_list_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/update_attendance_status_extension.dart'; @@ -115,6 +118,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final GetAllIdentitiesInteractor _getAllIdentitiesInteractor; final StoreOpenedEmailInteractor _storeOpenedEmailInteractor; final PrintEmailInteractor _printEmailInteractor; + final AddALabelToAnEmailInteractor addALabelToAnEmailInteractor; final EmailId? _currentEmailId; CreateNewEmailRuleFilterInteractor? _createNewEmailRuleFilterInteractor; @@ -184,6 +188,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { this._markAsStarEmailInteractor, this._getAllIdentitiesInteractor, this._storeOpenedEmailInteractor, + this.addALabelToAnEmailInteractor, this._printEmailInteractor, { EmailId? currentEmailId, }) : _currentEmailId = currentEmailId; @@ -240,6 +245,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { _handlePrintEmailSuccess(success); } else if (success is CalendarEventReplySuccess) { calendarEventSuccess(success); + } else if (success is AddALabelToAnEmailSuccess) { + handleAddLabelToEmailSuccess(success); } else { super.handleSuccessViewState(success); } @@ -257,6 +264,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { _showMessageWhenEmailPrintingFailed(failure); } else if (failure is CalendarEventReplyFailure) { _calendarEventFailure(failure); + } else if (failure is AddALabelToAnEmailFailure) { + handleAddLabelToEmailFailure(failure); } else { super.handleFailureViewState(failure); } @@ -887,6 +896,10 @@ class SingleEmailController extends BaseController with AppLoaderMixin { case EmailActionType.compose: pressEmailAction(actionType, presentationEmail); break; + case EmailActionType.labelAs: + if (!isLabelFeatureEnabled) return; + openAddLabelToEmailDialogModal(presentationEmail); + break; default: break; } diff --git a/lib/features/email/presentation/email_view.dart b/lib/features/email/presentation/email_view.dart index baf1b26cc2..058fb34d46 100644 --- a/lib/features/email/presentation/email_view.dart +++ b/lib/features/email/presentation/email_view.dart @@ -22,6 +22,7 @@ import 'package:tmail_ui_user/features/base/widget/optional_expanded.dart'; import 'package:tmail_ui_user/features/base/widget/optional_scroll.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_event_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/handle_label_for_email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/handle_on_iframe_click_in_email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/validate_display_free_busy_message_extension.dart'; @@ -102,8 +103,16 @@ class EmailView extends GetWidget { handleEmailAction: controller.handleEmailAction, additionalActions: [], emailIsRead: presentationEmail.hasRead, + isLabelFeatureEnabled: controller.isLabelFeatureEnabled, + labels: controller.mailboxDashBoardController.labelController.labels, openBottomSheetContextMenu: controller.mailboxDashBoardController.openBottomSheetContextMenu, openPopupMenu: controller.mailboxDashBoardController.openPopupMenuActionGroup, + onSelectLabelAction: (label, isSelected) => + controller.toggleLabelToEmail( + presentationEmail.id!, + label, + isSelected, + ), ); }, supportBackAction: !isInsideThreadDetailView, @@ -307,8 +316,16 @@ class EmailView extends GetWidget { EmailActionType.deletePermanently, ], emailIsRead: presentationEmail.hasRead, + isLabelFeatureEnabled: controller.isLabelFeatureEnabled, + labels: controller.mailboxDashBoardController.labelController.labels, openBottomSheetContextMenu: controller.mailboxDashBoardController.openBottomSheetContextMenu, openPopupMenu: controller.mailboxDashBoardController.openPopupMenuActionGroup, + onSelectLabelAction: (label, isSelected) => + controller.toggleLabelToEmail( + presentationEmail.id!, + label, + isSelected, + ), ), onToggleThreadDetailCollapseExpand: onToggleThreadDetailCollapseExpand, mailboxContain: presentationEmail.findMailboxContain( @@ -579,12 +596,13 @@ class EmailView extends GetWidget { ), ), Obx(() { + final dialogRouter = DialogRouter(); bool isOverlayEnabled = controller.mailboxDashBoardController.isDisplayedOverlayViewOnIFrame || MessageDialogActionManager().isDialogOpened || EmailActionReactor.isDialogOpened || ColorDialogPicker().isOpened.isTrue || - DialogRouter.isRuleFilterDialogOpened.isTrue || - DialogRouter.isDialogOpened; + dialogRouter.isRuleFilterDialogOpened.isTrue || + dialogRouter.isDialogOpened; if (isOverlayEnabled) { return Positioned.fill( diff --git a/lib/features/email/presentation/extensions/email_extension.dart b/lib/features/email/presentation/extensions/email_extension.dart index 123e24c5af..b0aa6475b0 100644 --- a/lib/features/email/presentation/extensions/email_extension.dart +++ b/lib/features/email/presentation/extensions/email_extension.dart @@ -1,11 +1,13 @@ import 'package:core/utils/app_logger.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:model/extensions/email_extension.dart'; import 'package:model/extensions/list_email_header_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/smime_signature_status.dart'; import 'package:tmail_ui_user/features/email/presentation/utils/smime_signature_constant.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; extension EmailExtension on Email { @@ -39,7 +41,7 @@ extension EmailExtension on Email { bool fromMe(String ownEmailAddress) { return from?.any( - (emailAdress) => emailAdress.email == ownEmailAddress + (emailAddress) => emailAddress.email == ownEmailAddress ) == true; } @@ -50,7 +52,15 @@ extension EmailExtension on Email { ...bcc ?? {}, }; return recipients.any( - (emailAdress) => emailAdress.email == ownEmailAddress + (emailAddress) => emailAddress.email == ownEmailAddress ) == true; } + + Email toggleKeyword(KeyWordIdentifier keyword, bool remove) { + return copyWith( + keywords: remove + ? keywords.withoutKeyword(keyword) + : keywords.withKeyword(keyword), + ); + } } \ No newline at end of file diff --git a/lib/features/email/presentation/extensions/email_loaded_extension.dart b/lib/features/email/presentation/extensions/email_loaded_extension.dart new file mode 100644 index 0000000000..982cdbb1ca --- /dev/null +++ b/lib/features/email/presentation/extensions/email_loaded_extension.dart @@ -0,0 +1,20 @@ +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/email_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; + +extension EmailLoadedExtension on EmailLoaded { + EmailLoaded toggleEmailKeyword({ + required EmailId emailId, + required KeyWordIdentifier keyword, + required bool remove, + }) { + final current = emailCurrent; + if (current == null || current.id != emailId) { + return this; + } + return copyWith( + emailCurrent: current.toggleKeyword(keyword, remove), + ); + } +} diff --git a/lib/features/email/presentation/extensions/handle_label_for_email_extension.dart b/lib/features/email/presentation/extensions/handle_label_for_email_extension.dart new file mode 100644 index 0000000000..5caf995d3e --- /dev/null +++ b/lib/features/email/presentation/extensions/handle_label_for_email_extension.dart @@ -0,0 +1,189 @@ +import 'package:core/utils/platform_info.dart'; +import 'package:dartz/dartz.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +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/keyword_identifier.dart'; +import 'package:labels/extensions/label_extension.dart'; +import 'package:labels/model/label.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/email/domain/state/add_a_label_to_an_email_state.dart'; +import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/email_loaded_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart'; +import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; +import 'package:tmail_ui_user/features/labels/domain/exceptions/label_exceptions.dart'; +import 'package:tmail_ui_user/features/labels/presentation/widgets/add_label_to_email_modal.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/update_current_emails_flags_extension.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; +import 'package:tmail_ui_user/features/thread/domain/extensions/presentation_email_map_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart'; +import 'package:tmail_ui_user/main/routes/dialog_router.dart'; + +extension HandleLabelForEmailExtension on SingleEmailController { + bool get isLabelFeatureEnabled { + return mailboxDashBoardController.isLabelCapabilitySupported && + mailboxDashBoardController.labelController.isLabelSettingEnabled.isTrue; + } + + void toggleLabelToEmail(EmailId emailId, Label label, bool isSelected) { + if (isSelected) { + final accountId = mailboxDashBoardController.accountId.value; + final session = mailboxDashBoardController.sessionCurrent; + + _addALabelToAnEmail( + session: session, + accountId: accountId, + emailId: emailId, + label: label, + ); + } + } + + void _addALabelToAnEmail({ + required Session? session, + required AccountId? accountId, + required Label label, + required EmailId emailId, + }) { + final labelDisplay = label.safeDisplayName; + + if (session == null) { + consumeState( + Stream.value( + Left(AddALabelToAnEmailFailure( + exception: NotFoundSessionException(), + labelDisplay: labelDisplay, + )), + ), + ); + return; + } + + if (accountId == null) { + consumeState( + Stream.value( + Left(AddALabelToAnEmailFailure( + exception: NotFoundAccountIdException(), + labelDisplay: labelDisplay, + )), + ), + ); + return; + } + + final labelKeyword = label.keyword; + if (labelKeyword == null) { + consumeState( + Stream.value(Left( + AddALabelToAnEmailFailure( + exception: LabelKeywordIsNull(), + labelDisplay: labelDisplay, + ), + )), + ); + return; + } + + consumeState(addALabelToAnEmailInteractor.execute( + session, + accountId, + emailId, + labelKeyword, + label.safeDisplayName, + )); + } + + void handleAddLabelToEmailSuccess(AddALabelToAnEmailSuccess success) { + toastManager.showMessageSuccess(success); + + _autoSyncLabelToSelectedEmailOnMemory( + emailId: success.emailId, + labelKeyword: success.labelKeyword, + ); + } + + void handleAddLabelToEmailFailure(AddALabelToAnEmailFailure failure) { + toastManager.showMessageFailure(failure); + } + + void _autoSyncLabelToSelectedEmailOnMemory({ + required EmailId emailId, + required KeyWordIdentifier labelKeyword, + }) { + _updateLabelInEmailOnMemory( + emailId: emailId, + labelKeyword: labelKeyword, + isMobileThreadDisabled: PlatformInfo.isMobile && !isThreadDetailEnabled, + ); + } + + void _updateLabelInEmailOnMemory({ + required EmailId emailId, + required KeyWordIdentifier labelKeyword, + required bool isMobileThreadDisabled, + }) { + if (isMobileThreadDisabled) { + final selectedEmail = mailboxDashBoardController.selectedEmail.value; + if (selectedEmail?.id == emailId) { + mailboxDashBoardController.selectedEmail.value?.keywords + ?.addKeyword(labelKeyword); + } + } else { + final controller = threadDetailController; + if (controller != null) { + controller.emailIdsPresentation.value = + controller.emailIdsPresentation.toggleEmailKeywordById( + emailId: emailId, + keyword: labelKeyword, + remove: false, + ); + + controller.emailsInThreadDetailInfo.value = + controller.emailsInThreadDetailInfo.toggleEmailKeywordById( + emailId: emailId, + keyword: labelKeyword, + remove: false, + ); + } + } + + final emailLoaded = currentEmailLoaded.value; + if (emailLoaded != null && emailLoaded.emailCurrent?.id == emailId) { + currentEmailLoaded.value = emailLoaded.toggleEmailKeyword( + emailId: emailId, + keyword: labelKeyword, + remove: false, + ); + } + + mailboxDashBoardController.updateEmailFlagByEmailIds( + [emailId], + isLabelAdded: true, + labelKeyword: labelKeyword, + ); + + mailboxDashBoardController.labelController.isLabelSettingEnabled.refresh(); + } + + Future openAddLabelToEmailDialogModal(PresentationEmail email) async { + if (!isLabelFeatureEnabled) return; + final labels = mailboxDashBoardController.labelController.labels; + final emailLabels = email.getLabelList(labels); + final emailId = email.id; + if (emailId == null || labels.isEmpty) { + return; + } + + await DialogRouter().openDialogModal( + child: AddLabelToEmailModal( + labels: labels, + emailLabels: emailLabels, + emailId: emailId, + onAddLabelToEmailCallback: toggleLabelToEmail, + ), + dialogLabel: 'add-label-to-email-modal', + ); + } +} diff --git a/lib/features/email/presentation/extensions/presentation_email_extension.dart b/lib/features/email/presentation/extensions/presentation_email_extension.dart index 7204f03b87..651b4631d4 100644 --- a/lib/features/email/presentation/extensions/presentation_email_extension.dart +++ b/lib/features/email/presentation/extensions/presentation_email_extension.dart @@ -1,10 +1,10 @@ import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:labels/model/label.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; import 'package:model/extensions/list_email_address_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; -import 'package:tmail_ui_user/features/thread/data/extensions/list_keyword_identifier_extension.dart'; import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; extension PresentationEmailExtension on PresentationEmail { @@ -160,15 +160,23 @@ extension PresentationEmailExtension on PresentationEmail { List