From 3b96eeeb200b1e59cd3641d7a95359c8e49c996e Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 2 Dec 2024 14:09:27 +0700 Subject: [PATCH] TF-2666 Edit one recipient in `To/Cc/Bcc` on web Signed-off-by: dab246 --- .../base/widget/smart_interaction_widget.dart | 150 ++++++++++++++++++ .../presentation/composer_view_web.dart | 13 ++ .../handle_edit_recipient_extension.dart | 128 +++++++++++++++ .../model/email_address_action_type.dart | 26 +++ ...p_menu_item_email_address_action_type.dart | 22 +++ .../widgets/recipient_composer_widget.dart | 10 ++ .../widgets/recipient_tag_item_widget.dart | 24 ++- lib/l10n/intl_messages.arb | 12 ++ lib/main/localizations/app_localizations.dart | 14 ++ 9 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 lib/features/base/widget/smart_interaction_widget.dart create mode 100644 lib/features/composer/presentation/extensions/handle_edit_recipient_extension.dart create mode 100644 lib/features/composer/presentation/model/email_address_action_type.dart create mode 100644 lib/features/composer/presentation/model/popup_menu_item_email_address_action_type.dart diff --git a/lib/features/base/widget/smart_interaction_widget.dart b/lib/features/base/widget/smart_interaction_widget.dart new file mode 100644 index 000000000..b9bd19cd6 --- /dev/null +++ b/lib/features/base/widget/smart_interaction_widget.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:core/utils/platform_info.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' hide OverlayEntry; +import 'package:universal_html/html.dart' as html; + +typedef OnRightMouseClickAction = void Function(RelativeRect position); +typedef OnDoubleClickAction = void Function(RelativeRect position); + +class SmartInteractionWidget extends StatefulWidget { + final Widget child; + final OnRightMouseClickAction onRightMouseClickAction; + final OnDoubleClickAction onDoubleClickAction; + + const SmartInteractionWidget({ + super.key, + required this.child, + required this.onRightMouseClickAction, + required this.onDoubleClickAction, + }); + + @override + State createState() => _SmartInteractionWidgetState(); +} + +class _SmartInteractionWidgetState extends State { + final GlobalKey _childKey = GlobalKey(); + + Offset? _lastPointerEventOffset; + StreamSubscription? _contextMenuSubscription; + + @override + void initState() { + super.initState(); + if (PlatformInfo.isWeb) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _addWebContextMenuListener(); + }); + } + } + + void _addWebContextMenuListener() { + _contextMenuSubscription = + html.document.onContextMenu.listen(_onContextMenuListener); + } + + void _onContextMenuListener(html.MouseEvent event) { + final renderBox = + _childKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final localPosition = Offset( + event.page.x.toDouble(), + event.page.y.toDouble(), + ); + final renderBoxPosition = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + + final rect = Rect.fromLTWH( + renderBoxPosition.dx, + renderBoxPosition.dy, + size.width, + size.height, + ); + + if (rect.contains(localPosition)) { + event.preventDefault(); + + if (!mounted) return; + + final relativeRect = _getRelativeRectFromOffset(context, localPosition); + widget.onRightMouseClickAction(relativeRect); + } + } + + RelativeRect _getRelativeRectFromOffset( + BuildContext context, + Offset offset, + ) { + final widgetBox = + _childKey.currentContext?.findRenderObject() as RenderBox?; + final overlayBox = + Overlay.maybeOf(context)?.context.findRenderObject() as RenderBox?; + + if (widgetBox == null || overlayBox == null) { + final screenSize = MediaQuery.sizeOf(context); + return RelativeRect.fromLTRB( + offset.dx, + offset.dy, + screenSize.width - offset.dx, + screenSize.height - offset.dy, + ); + } else { + final targetPosition = widgetBox.localToGlobal( + Offset.zero, + ancestor: overlayBox, + ); + final overlaySize = overlayBox.size; + const topSpace = 4.0; + + return RelativeRect.fromLTRB( + targetPosition.dx, + targetPosition.dy + widgetBox.size.height + topSpace, + overlaySize.width + targetPosition.dx, + overlaySize.height - targetPosition.dy - widgetBox.size.height, + ); + } + } + + void _handleDoubleTap() { + if (_lastPointerEventOffset != null) { + final relativeRect = _getRelativeRectFromOffset( + context, + _lastPointerEventOffset!, + ); + widget.onDoubleClickAction(relativeRect); + } + } + + @override + Widget build(BuildContext context) { + if (PlatformInfo.isWeb) { + return Listener( + key: _childKey, + onPointerDown: (event) { + if (event.kind == PointerDeviceKind.mouse || + event.kind == PointerDeviceKind.touch) { + _lastPointerEventOffset = event.position; + } + }, + child: GestureDetector( + onDoubleTap: _handleDoubleTap, + child: widget.child, + ), + ); + } else { + return widget.child; + } + } + + @override + void dispose() { + _lastPointerEventOffset = null; + if (PlatformInfo.isWeb) { + _contextMenuSubscription?.cancel(); + } + super.dispose(); + } +} diff --git a/lib/features/composer/presentation/composer_view_web.dart b/lib/features/composer/presentation/composer_view_web.dart index 74e47a7f7..a86a1a4d8 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -6,6 +6,7 @@ import 'package:model/email/prefix_email_address.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/composer_print_draft_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_edit_recipient_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/mark_as_important_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/remove_draggable_email_address_between_recipient_fields_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/prefix_recipient_state.dart'; @@ -120,6 +121,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.ccRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -145,6 +147,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.bccRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -170,6 +173,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -194,6 +198,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), ], )), @@ -389,6 +394,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.ccRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -414,6 +420,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.bccRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -439,6 +446,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -463,6 +471,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), ], )), @@ -701,6 +710,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.ccRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -726,6 +736,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.bccRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -751,6 +762,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) RecipientComposerWidget( @@ -775,6 +787,7 @@ class ComposerView extends GetWidget { onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, onFocusNextAddressAction: controller.handleFocusNextAddressAction, onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + onEditRecipientAction: controller.onEditRecipient, ), ], )), diff --git a/lib/features/composer/presentation/extensions/handle_edit_recipient_extension.dart b/lib/features/composer/presentation/extensions/handle_edit_recipient_extension.dart new file mode 100644 index 000000000..c188b3203 --- /dev/null +++ b/lib/features/composer/presentation/extensions/handle_edit_recipient_extension.dart @@ -0,0 +1,128 @@ + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/email/prefix_email_address.dart'; +import 'package:model/extensions/email_address_extension.dart'; +import 'package:model/mailbox/expand_mode.dart'; +import 'package:tmail_ui_user/features/base/widget/popup_menu/popup_menu_item_action_widget.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/email_address_action_type.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/popup_menu_item_email_address_action_type.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; + +extension HandleEditRecipientExtension on ComposerController { + void onEditRecipient( + BuildContext context, + PrefixEmailAddress prefix, + EmailAddress emailAddress, + RelativeRect position, + ) { + final popupMenuItems = EmailAddressActionType.values.map((type) { + return PopupMenuItem( + padding: EdgeInsets.zero, + child: PopupMenuItemActionWidget( + menuAction: PopupMenuItemEmailAddressActionType( + type, + AppLocalizations.of(context), + imagePaths, + ), + menuActionClick: (menuAction) { + popBack(); + _handleEmailAddressActionTypeClick( + context, + menuAction.action, + prefix, + emailAddress, + ); + }, + ), + ); + }).toList(); + + openPopupMenuAction(context, position, popupMenuItems); + } + + void _handleEmailAddressActionTypeClick( + BuildContext context, + EmailAddressActionType actionType, + PrefixEmailAddress prefix, + EmailAddress emailAddress, + ) { + switch (actionType) { + case EmailAddressActionType.copy: + _copyEmailAddress(context, emailAddress); + break; + case EmailAddressActionType.modify: + _modifyEmailAddress(prefix, emailAddress); + break; + } + } + + void _copyEmailAddress(BuildContext context, EmailAddress emailAddress) { + Clipboard.setData(ClipboardData(text: emailAddress.emailAddress)); + appToast.showToastSuccessMessage( + context, + AppLocalizations.of(context).email_address_copied_to_clipboard, + ); + } + + void _modifyEmailAddress( + PrefixEmailAddress prefix, + EmailAddress emailAddress, + ) { + switch(prefix) { + case PrefixEmailAddress.to: + listToEmailAddress.remove(emailAddress); + toAddressExpandMode.value = ExpandMode.EXPAND; + toAddressExpandMode.refresh(); + + toEmailAddressController.text = emailAddress.emailAddress; + toEmailAddressController.value = toEmailAddressController.value.copyWith( + text: emailAddress.emailAddress, + selection: TextSelection( + baseOffset: emailAddress.emailAddress.length, + extentOffset: emailAddress.emailAddress.length, + ), + composing: TextRange.empty, + ); + toAddressFocusNode?.requestFocus(); + break; + case PrefixEmailAddress.cc: + listCcEmailAddress.remove(emailAddress); + ccAddressExpandMode.value = ExpandMode.EXPAND; + ccAddressExpandMode.refresh(); + + ccEmailAddressController.text = emailAddress.emailAddress; + ccEmailAddressController.value = ccEmailAddressController.value.copyWith( + text: emailAddress.emailAddress, + selection: TextSelection( + baseOffset: emailAddress.emailAddress.length, + extentOffset: emailAddress.emailAddress.length, + ), + composing: TextRange.empty, + ); + ccAddressFocusNode?.requestFocus(); + break; + case PrefixEmailAddress.bcc: + listBccEmailAddress.remove(emailAddress); + bccAddressExpandMode.value = ExpandMode.EXPAND; + bccAddressExpandMode.refresh(); + + bccEmailAddressController.text = emailAddress.emailAddress; + bccEmailAddressController.value = bccEmailAddressController.value.copyWith( + text: emailAddress.emailAddress, + selection: TextSelection( + baseOffset: emailAddress.emailAddress.length, + extentOffset: emailAddress.emailAddress.length, + ), + composing: TextRange.empty, + ); + bccAddressFocusNode?.requestFocus(); + break; + default: + break; + } + } +} \ No newline at end of file diff --git a/lib/features/composer/presentation/model/email_address_action_type.dart b/lib/features/composer/presentation/model/email_address_action_type.dart new file mode 100644 index 000000000..9be2d26ca --- /dev/null +++ b/lib/features/composer/presentation/model/email_address_action_type.dart @@ -0,0 +1,26 @@ + +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +enum EmailAddressActionType { + copy, + modify; + + String getContextMenuTitle(AppLocalizations appLocalizations) { + switch(this) { + case EmailAddressActionType.copy: + return appLocalizations.copy; + case EmailAddressActionType.modify: + return appLocalizations.modifyEmailAddress; + } + } + + String getContextMenuIcon(ImagePaths imagePaths) { + switch(this) { + case EmailAddressActionType.copy: + return imagePaths.icCopy; + case EmailAddressActionType.modify: + return imagePaths.icEditRule; + } + } +} \ No newline at end of file diff --git a/lib/features/composer/presentation/model/popup_menu_item_email_address_action_type.dart b/lib/features/composer/presentation/model/popup_menu_item_email_address_action_type.dart new file mode 100644 index 000000000..99bdbfe49 --- /dev/null +++ b/lib/features/composer/presentation/model/popup_menu_item_email_address_action_type.dart @@ -0,0 +1,22 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:tmail_ui_user/features/base/model/popup_menu_item_action.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/email_address_action_type.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +class PopupMenuItemEmailAddressActionType + extends PopupMenuItemActionRequiredIcon { + final AppLocalizations appLocalizations; + final ImagePaths imagePaths; + + PopupMenuItemEmailAddressActionType( + super.action, + this.appLocalizations, + this.imagePaths, + ); + + @override + String get actionIcon => action.getContextMenuIcon(imagePaths); + + @override + String get actionName => action.getContextMenuTitle(appLocalizations); +} diff --git a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart index 500fbfc22..25e181bf6 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -37,6 +37,12 @@ typedef OnFocusEmailAddressChangeAction = void Function(PrefixEmailAddress prefi typedef OnRemoveDraggableEmailAddressAction = void Function(DraggableEmailAddress draggableEmailAddress); typedef OnDeleteTagAction = void Function(EmailAddress emailAddress); typedef OnEnableAllRecipientsInputAction = void Function(bool isEnabled); +typedef OnEditRecipientAction = void Function( + BuildContext context, + PrefixEmailAddress prefix, + EmailAddress emailAddress, + RelativeRect position, +); class RecipientComposerWidget extends StatefulWidget { @@ -62,6 +68,7 @@ class RecipientComposerWidget extends StatefulWidget { final OnShowFullListEmailAddressAction? onShowFullListEmailAddressAction; final OnFocusEmailAddressChangeAction? onFocusEmailAddressChangeAction; final OnRemoveDraggableEmailAddressAction? onRemoveDraggableEmailAddressAction; + final OnEditRecipientAction? onEditRecipientAction; final VoidCallback? onFocusNextAddressAction; final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? margin; @@ -101,6 +108,7 @@ class RecipientComposerWidget extends StatefulWidget { this.onRemoveDraggableEmailAddressAction, this.onEnableAllRecipientsInputAction, this.focusNodeKeyboard, + this.onEditRecipientAction, }); @override @@ -217,6 +225,7 @@ class _RecipientComposerWidgetState extends State { maxWidth: widget.maxWidth, onDeleteTagAction: (emailAddress) => _handleDeleteTagAction.call(emailAddress, stateSetter), onShowFullAction: widget.onShowFullListEmailAddressAction, + onEditRecipientAction: widget.onEditRecipientAction, ); }, onTagChanged: (value) => _handleOnTagChangeAction.call(value, stateSetter), @@ -309,6 +318,7 @@ class _RecipientComposerWidgetState extends State { maxWidth: widget.maxWidth, onDeleteTagAction: (emailAddress) => _handleDeleteTagAction.call(emailAddress, stateSetter), onShowFullAction: widget.onShowFullListEmailAddressAction, + onEditRecipientAction: widget.onEditRecipientAction, ); }, onTagChanged: (value) => _handleOnTagChangeAction.call(value, stateSetter), diff --git a/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart b/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart index 172d8bcbb..cd02fb1eb 100644 --- a/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart @@ -10,6 +10,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/email/prefix_email_address.dart'; import 'package:model/extensions/email_address_extension.dart'; +import 'package:tmail_ui_user/features/base/widget/smart_interaction_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart'; import 'package:tmail_ui_user/features/composer/presentation/styles/recipient_tag_item_widget_style.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/draggable_recipient_tag_widget.dart'; @@ -30,6 +31,7 @@ class RecipientTagItemWidget extends StatelessWidget { final List collapsedListEmailAddress; final OnShowFullListEmailAddressAction? onShowFullAction; final OnDeleteTagAction? onDeleteTagAction; + final OnEditRecipientAction? onEditRecipientAction; final bool isTestingForWeb; final String? composerId; @@ -47,6 +49,7 @@ class RecipientTagItemWidget extends StatelessWidget { this.isLatestEmail = false, this.onShowFullAction, this.onDeleteTagAction, + this.onEditRecipientAction, this.maxWidth, this.composerId, }); @@ -100,7 +103,17 @@ class RecipientTagItemWidget extends StatelessWidget { childWhenDragging: DraggableRecipientTagWidget(emailAddress: currentEmailAddress), child: MouseRegion( cursor: SystemMouseCursors.grab, - child: tagWidget, + child: SmartInteractionWidget( + onRightMouseClickAction: (position) => _onEditRecipientAction( + context, + position, + ), + onDoubleClickAction: (position) => _onEditRecipientAction( + context, + position, + ), + child: tagWidget, + ), ), ); } @@ -171,4 +184,13 @@ class RecipientTagItemWidget extends StatelessWidget { ); } } + + void _onEditRecipientAction(BuildContext context, RelativeRect position) { + onEditRecipientAction?.call( + context, + prefix, + currentEmailAddress, + position, + ); + } } \ No newline at end of file diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 2f54e00b9..81bc5f526 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -4601,5 +4601,17 @@ "placeholders": { "days": {} } + }, + "copy": "Copy", + "@copy": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "modifyEmailAddress": "Modify email address", + "@modifyEmailAddress": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index e040eaf8c..190a0d31e 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -4848,4 +4848,18 @@ class AppLocalizations { args: [days], ); } + + String get copy { + return Intl.message( + 'Copy', + name: 'copy', + ); + } + + String get modifyEmailAddress { + return Intl.message( + 'Modify email address', + name: 'modifyEmailAddress', + ); + } }