From e520b87301bbe46c00b8e897c6c7506cd7778d3b Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 5 Dec 2025 18:10:39 +0700 Subject: [PATCH 1/6] TF-4190 Performance issue to edit thread favorites --- .../controller/single_email_controller.dart | 29 +---- .../extensions/email_loaded_extension.dart | 38 ++++++ .../handle_email_action_extension.dart | 81 +++++++++++++ .../presentation_email_extension.dart | 17 +++ .../presentation/model/email_loaded.dart | 14 +++ .../mailbox_dashboard_controller.dart | 9 +- .../mark_as_star_multiple_email_state.dart | 35 ++++-- ...ark_as_star_multiple_email_interactor.dart | 7 +- ...email_in_thread_detail_info_extension.dart | 82 ++++++++++++- .../presentation_email_map_extension.dart | 111 ++++++++++++++++++ .../handle_thread_action_success.dart | 56 +++++++++ .../on_thread_detail_action_click.dart | 1 + .../thread_detail_controller.dart | 5 + .../presentation_email_extension.dart | 5 - 14 files changed, 442 insertions(+), 48 deletions(-) create mode 100644 lib/features/email/presentation/extensions/email_loaded_extension.dart create mode 100644 lib/features/email/presentation/extensions/handle_email_action_extension.dart create mode 100644 lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart create mode 100644 lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 0dc87d033e..a59c22f0ca 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -18,7 +18,6 @@ import 'package:jmap_dart_client/jmap/mail/calendar/properties/attendee/calendar import 'package:jmap_dart_client/jmap/mail/calendar/properties/calendar_organizer.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; -import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:jmap_dart_client/jmap/mdn/disposition.dart'; import 'package:jmap_dart_client/jmap/mdn/mdn.dart'; import 'package:model/error_type_handler/unknown_uri_exception.dart'; @@ -65,6 +64,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_email_action_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'; @@ -82,7 +82,6 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_download_attachment_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_preview_attachment_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/open_and_close_composer_extension.dart'; -import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/update_current_emails_flags_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dashboard_routes.dart'; import 'package:tmail_ui_user/features/manage_account/domain/state/create_new_rule_filter_state.dart'; import 'package:tmail_ui_user/features/manage_account/domain/state/get_all_identities_state.dart'; @@ -223,7 +222,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } else if (success is MarkAsEmailReadSuccess) { _handleMarkAsEmailReadCompleted(success); } else if (success is MarkAsStarEmailSuccess) { - _markAsEmailStarSuccess(success); + markAsEmailStarSuccess(success); } else if (success is GetAllIdentitiesSuccess) { _getAllIdentitiesSuccess(success); } else if (success is SendReceiptToSenderSuccess) { @@ -783,30 +782,6 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } } - void _markAsEmailStarSuccess(MarkAsStarEmailSuccess success) { - final newKeywords = { - KeyWordIdentifier.emailFlagged: - success.markStarAction == MarkStarAction.markStar, - }; - - final newEmail = currentEmail?.updateKeywords(newKeywords); - final emailId = newEmail?.id; - if (emailId == null) return; - - if (PlatformInfo.isMobile && !isThreadDetailEnabled) { - mailboxDashBoardController.selectedEmail.value?.resyncKeywords(newKeywords); - } else { - _threadDetailController?.emailIdsPresentation[emailId] = newEmail; - } - - mailboxDashBoardController.updateEmailFlagByEmailIds( - [emailId], - markStarAction: success.markStarAction, - ); - - toastManager.showMessageSuccess(success); - } - void handleEmailAction( PresentationEmail presentationEmail, EmailActionType actionType, 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..9c27548d55 --- /dev/null +++ b/lib/features/email/presentation/extensions/email_loaded_extension.dart @@ -0,0 +1,38 @@ +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:model/extensions/email_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; + +extension EmailLoadedExtension on EmailLoaded { + EmailLoaded starById(EmailId emailId) { + if (emailCurrent == null || emailCurrent!.id != emailId) { + return this; + } + + final updatedKeywords = Map.from( + emailCurrent!.keywords ?? {}, + )..[KeyWordIdentifier.emailFlagged] = true; + + final updatedEmail = emailCurrent!.copyWith( + keywords: updatedKeywords, + ); + + return copyWith(emailCurrent: updatedEmail); + } + + EmailLoaded unstarById(EmailId emailId) { + if (emailCurrent == null || emailCurrent!.id != emailId) { + return this; + } + + final updatedKeywords = Map.from( + emailCurrent!.keywords ?? {}, + )..remove(KeyWordIdentifier.emailFlagged); + + final updatedEmail = emailCurrent!.copyWith( + keywords: updatedKeywords, + ); + + return copyWith(emailCurrent: updatedEmail); + } +} diff --git a/lib/features/email/presentation/extensions/handle_email_action_extension.dart b/lib/features/email/presentation/extensions/handle_email_action_extension.dart new file mode 100644 index 0000000000..6725bf2efe --- /dev/null +++ b/lib/features/email/presentation/extensions/handle_email_action_extension.dart @@ -0,0 +1,81 @@ +import 'package:core/utils/platform_info.dart'; +import 'package:model/email/mark_star_action.dart'; +import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_star_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/mailbox_dashboard/presentation/extensions/update_current_emails_flags_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/features/thread_detail/domain/extensions/presentation_email_map_extension.dart'; + +extension HandleEmailActionExtension on SingleEmailController { + void markAsEmailStarSuccess(MarkAsStarEmailSuccess success) { + final isMark = success.markStarAction == MarkStarAction.markStar; + + if (PlatformInfo.isMobile && !isThreadDetailEnabled) { + _handleStarInMailboxContext(isMark); + } else { + _handleStarInThreadDetailContext(isMark); + } + + toastManager.showMessageSuccess(success); + } + + void _handleStarInMailboxContext(bool isMark) { + final selectedEmail = mailboxDashBoardController.selectedEmail.value; + if (selectedEmail == null) return; + + final emailId = selectedEmail.id!; + final emailLoaded = currentEmailLoaded.value; + + // Update selected email + mailboxDashBoardController.selectedEmail.value = + isMark ? selectedEmail.star() : selectedEmail.unstar(); + + // Update emailLoaded + if (emailLoaded != null) { + currentEmailLoaded.value = isMark + ? emailLoaded.starById(emailId) + : emailLoaded.unstarById(emailId); + } + + // Update flag to list emails in dashboard + mailboxDashBoardController.updateEmailFlagByEmailIds( + [emailId], + markStarAction: + isMark ? MarkStarAction.markStar : MarkStarAction.unMarkStar, + ); + } + + void _handleStarInThreadDetailContext(bool isMark) { + if (threadDetailController == null) return; + final controller = threadDetailController!; + final currentEmailId = currentEmail?.id; + if (currentEmailId == null) return; + + // Update list of ids + controller.emailIdsPresentation.value = isMark + ? controller.emailIdsPresentation.starOne(currentEmailId) + : controller.emailIdsPresentation.unstarOne(currentEmailId); + + // Update thread detail email infos + controller.emailsInThreadDetailInfo.value = isMark + ? controller.emailsInThreadDetailInfo.starOne(currentEmailId) + : controller.emailsInThreadDetailInfo.unstarOne(currentEmailId); + + // Update loaded email + final emailLoaded = controller.currentEmailLoaded.value; + if (emailLoaded != null) { + controller.currentEmailLoaded.value = isMark + ? emailLoaded.starById(currentEmailId) + : emailLoaded.unstarById(currentEmailId); + } + + // Update flag to emails in dashboard + mailboxDashBoardController.updateEmailFlagByEmailIds( + [currentEmailId], + markStarAction: + isMark ? MarkStarAction.markStar : MarkStarAction.unMarkStar, + ); + } +} diff --git a/lib/features/email/presentation/extensions/presentation_email_extension.dart b/lib/features/email/presentation/extensions/presentation_email_extension.dart index 385a4c61fe..acb589a88e 100644 --- a/lib/features/email/presentation/extensions/presentation_email_extension.dart +++ b/lib/features/email/presentation/extensions/presentation_email_extension.dart @@ -1,4 +1,5 @@ import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.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'; @@ -153,4 +154,20 @@ extension PresentationEmailExtension on PresentationEmail { replyTo: [], ); } + + PresentationEmail star() { + final updatedKeywords = Map.from( + keywords ?? {}, + )..[KeyWordIdentifier.emailFlagged] = true; + + return copyWith(keywords: updatedKeywords); + } + + PresentationEmail unstar() { + final updatedKeywords = Map.from( + keywords ?? {}, + )..remove(KeyWordIdentifier.emailFlagged); + + return copyWith(keywords: updatedKeywords); + } } \ No newline at end of file diff --git a/lib/features/email/presentation/model/email_loaded.dart b/lib/features/email/presentation/model/email_loaded.dart index 15d942d79e..0d0175be99 100644 --- a/lib/features/email/presentation/model/email_loaded.dart +++ b/lib/features/email/presentation/model/email_loaded.dart @@ -17,6 +17,20 @@ class EmailLoaded with EquatableMixin { this.emailCurrent, }); + EmailLoaded copyWith({ + String? htmlContent, + List? attachments, + List? inlineImages, + Email? emailCurrent, + }) { + return EmailLoaded( + htmlContent: htmlContent ?? this.htmlContent, + attachments: attachments ?? this.attachments, + inlineImages: inlineImages ?? this.inlineImages, + emailCurrent: emailCurrent ?? this.emailCurrent, + ); + } + SMimeSignatureStatus? get sMimeStatus => emailCurrent?.sMimeStatus; @override diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index 67ebc80126..04a12c1e8d 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -455,12 +455,14 @@ class MailboxDashBoardController extends ReloadableController success.markStarAction, success.countMarkStarSuccess, success.emailIds, + isThread: success.isThread, ); } else if (success is MarkAsStarMultipleEmailHasSomeEmailFailure) { _markAsStarMultipleEmailSuccess( success.markStarAction, success.countMarkStarSuccess, success.successEmailIds, + isThread: success.isThread, ); } else if (success is MoveMultipleEmailToMailboxAllSuccess || success is MoveMultipleEmailToMailboxHasSomeEmailFailure) { @@ -1271,10 +1273,11 @@ class MailboxDashBoardController extends ReloadableController void _markAsStarMultipleEmailSuccess( MarkStarAction markStarAction, int countMarkStarSuccess, - List emailIds, - ) { + List emailIds, { + bool isThread = false, + }) { updateEmailFlagByEmailIds(emailIds, markStarAction: markStarAction); - if (currentOverlayContext != null && currentContext != null) { + if (!isThread && currentOverlayContext != null && currentContext != null) { final message = markStarAction == MarkStarAction.unMarkStar ? AppLocalizations.of(currentContext!).marked_unstar_multiple_item(countMarkStarSuccess) : AppLocalizations.of(currentContext!).marked_star_multiple_item(countMarkStarSuccess); diff --git a/lib/features/thread/domain/state/mark_as_star_multiple_email_state.dart b/lib/features/thread/domain/state/mark_as_star_multiple_email_state.dart index c7c5cecabf..d389383d10 100644 --- a/lib/features/thread/domain/state/mark_as_star_multiple_email_state.dart +++ b/lib/features/thread/domain/state/mark_as_star_multiple_email_state.dart @@ -3,21 +3,28 @@ import 'package:core/presentation/state/success.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:model/email/mark_star_action.dart'; -class LoadingMarkAsStarMultipleEmailAll extends UIState {} +class LoadingMarkAsStarMultipleEmailAll extends LoadingState {} class MarkAsStarMultipleEmailAllSuccess extends UIState { final int countMarkStarSuccess; final MarkStarAction markStarAction; final List emailIds; + final bool isThread; MarkAsStarMultipleEmailAllSuccess( this.countMarkStarSuccess, this.markStarAction, - this.emailIds, - ); + this.emailIds, { + this.isThread = false, + }); @override - List get props => [countMarkStarSuccess, markStarAction, emailIds]; + List get props => [ + countMarkStarSuccess, + markStarAction, + emailIds, + isThread, + ]; } class MarkAsStarMultipleEmailAllFailure extends FeatureFailure { @@ -33,22 +40,32 @@ class MarkAsStarMultipleEmailHasSomeEmailFailure extends UIState { final int countMarkStarSuccess; final MarkStarAction markStarAction; final List successEmailIds; + final bool isThread; MarkAsStarMultipleEmailHasSomeEmailFailure( this.countMarkStarSuccess, this.markStarAction, - this.successEmailIds, - ); + this.successEmailIds, { + this.isThread = false, + }); @override - List get props => [countMarkStarSuccess, markStarAction, successEmailIds]; + List get props => [ + countMarkStarSuccess, + markStarAction, + successEmailIds, + isThread, + ]; } class MarkAsStarMultipleEmailFailure extends FeatureFailure { final MarkStarAction markStarAction; - MarkAsStarMultipleEmailFailure(this.markStarAction, dynamic exception) : super(exception: exception); + MarkAsStarMultipleEmailFailure( + this.markStarAction, + dynamic exception, + ) : super(exception: exception); @override List get props => [markStarAction, exception]; -} \ No newline at end of file +} diff --git a/lib/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart b/lib/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart index 980a1ff967..111a4ecf8a 100644 --- a/lib/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart +++ b/lib/features/thread/domain/usecases/mark_as_star_multiple_email_interactor.dart @@ -17,8 +17,9 @@ class MarkAsStarMultipleEmailInteractor { Session session, AccountId accountId, List emailIds, - MarkStarAction markStarAction - ) async* { + MarkStarAction markStarAction, { + bool isThread = false, + }) async* { try { yield Right(LoadingMarkAsStarMultipleEmailAll()); @@ -29,6 +30,7 @@ class MarkAsStarMultipleEmailInteractor { emailIds.length, markStarAction, result.emailIdsSuccess, + isThread: isThread, )); } else if (result.emailIdsSuccess.isEmpty) { yield Left(MarkAsStarMultipleEmailAllFailure(markStarAction)); @@ -37,6 +39,7 @@ class MarkAsStarMultipleEmailInteractor { result.emailIdsSuccess.length, markStarAction, result.emailIdsSuccess, + isThread: isThread, )); } } catch (e) { diff --git a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart index d33fdea0d6..54dd93b5b2 100644 --- a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart +++ b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart @@ -1,10 +1,88 @@ 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/thread_detail/domain/model/email_in_thread_detail_info.dart'; -extension ListEmailInThreadDetailInfoExtension on List { +extension ListEmailInThreadDetailInfoExtension + on List { List emailIdsToDisplay(bool isSentMailbox) => isSentMailbox ? map((email) => email.emailId).toList() : where((email) => email.isValidToDisplay) .map((email) => email.emailId) .toList(); -} \ No newline at end of file + + List starAll() { + return map((email) { + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..[KeyWordIdentifier.emailFlagged] = true; + + return email.copyWith(keywords: updatedKeywords); + }).toList(); + } + + List unstarAll() { + return map((email) { + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..remove(KeyWordIdentifier.emailFlagged); + + return email.copyWith(keywords: updatedKeywords); + }).toList(); + } + + List starByEmailIds(List targetIds) { + final targetSet = targetIds.toSet(); + + return map((email) { + if (!targetSet.contains(email.emailId)) return email; + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..[KeyWordIdentifier.emailFlagged] = true; + + return email.copyWith(keywords: updatedKeywords); + }).toList(); + } + + List unstarByEmailIds(List targetIds) { + final targetSet = targetIds.toSet(); + + return map((email) { + if (!targetSet.contains(email.emailId)) return email; + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..remove(KeyWordIdentifier.emailFlagged); + + return email.copyWith(keywords: updatedKeywords); + }).toList(); + } + + List starOne(EmailId emailId) { + return map((email) { + if (email.emailId != emailId) { + return email; + } + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..[KeyWordIdentifier.emailFlagged] = true; + + return email.copyWith(keywords: updatedKeywords); + }).toList(); + } + + List unstarOne(EmailId emailId) { + return map((email) { + if (email.emailId != emailId) { + return email; + } + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..remove(KeyWordIdentifier.emailFlagged); + + return email.copyWith(keywords: updatedKeywords); + }).toList(); + } +} diff --git a/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart new file mode 100644 index 0000000000..7beee964d0 --- /dev/null +++ b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart @@ -0,0 +1,111 @@ +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/presentation_email.dart'; + +extension PresentationEmailMapExtension on Map { + Map starAll() { + return map((id, email) { + if (email == null) { + return MapEntry(id, email); + } + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..[KeyWordIdentifier.emailFlagged] = true; + + return MapEntry( + id, + email.copyWith(keywords: updatedKeywords), + ); + }); + } + + Map unstarAll() { + return map((id, email) { + if (email == null) { + return MapEntry(id, email); + } + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..remove(KeyWordIdentifier.emailFlagged); + + return MapEntry( + id, + email.copyWith(keywords: updatedKeywords), + ); + }); + } + + Map starByIds(List ids) { + final targetSet = ids.toSet(); + + return map((id, email) { + if (!targetSet.contains(id) || email == null) { + return MapEntry(id, email); + } + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..[KeyWordIdentifier.emailFlagged] = true; + + return MapEntry( + id, + email.copyWith(keywords: updatedKeywords), + ); + }); + } + + Map unstarByIds(List ids) { + final targetSet = ids.toSet(); + + return map((id, email) { + if (!targetSet.contains(id) || email == null) { + return MapEntry(id, email); + } + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..remove(KeyWordIdentifier.emailFlagged); + + return MapEntry( + id, + email.copyWith(keywords: updatedKeywords), + ); + }); + } + + Map starOne(EmailId emailId) { + return map((id, email) { + if (id != emailId || email == null) { + return MapEntry(id, email); + } + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..[KeyWordIdentifier.emailFlagged] = true; + + return MapEntry( + id, + email.copyWith(keywords: updatedKeywords), + ); + }); + } + + Map unstarOne(EmailId emailId) { + return map((id, email) { + if (id != emailId || email == null) { + return MapEntry(id, email); + } + + final updatedKeywords = Map.from( + email.keywords ?? {}, + )..remove(KeyWordIdentifier.emailFlagged); + + return MapEntry( + id, + email.copyWith(keywords: updatedKeywords), + ); + }); + } +} diff --git a/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart b/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart new file mode 100644 index 0000000000..4dba10739f --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart @@ -0,0 +1,56 @@ +import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:model/email/mark_star_action.dart'; +import 'package:tmail_ui_user/features/thread/domain/state/mark_as_star_multiple_email_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/extensions/presentation_email_map_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +extension HandleThreadActionSuccess on ThreadDetailController { + void handleMarkThreadAsStarredSuccess(Success success) { + mailboxDashBoardController.consumeState(Stream.value(Right(success))); + + switch (success) { + case MarkAsStarMultipleEmailAllSuccess(): + _handleMarkAll(success.markStarAction); + case MarkAsStarMultipleEmailHasSomeEmailFailure(): + _handleMarkPartial( + success.markStarAction, + success.successEmailIds, + ); + default: + break; + } + } + + void _handleMarkAll(MarkStarAction action) { + final currentEmailIdsPresentation = emailIdsPresentation; + emailIdsPresentation.value = action == MarkStarAction.markStar + ? currentEmailIdsPresentation.starAll() + : currentEmailIdsPresentation.unstarAll(); + + final currentEmailsInThreadDetailInfo = emailsInThreadDetailInfo; + log('$runtimeType::_handleMarkAll: currentEmailsInThreadDetailInfo = ${currentEmailsInThreadDetailInfo.length}'); + emailsInThreadDetailInfo.value = action == MarkStarAction.markStar + ? currentEmailsInThreadDetailInfo.starAll() + : currentEmailsInThreadDetailInfo.unstarAll(); + } + + void _handleMarkPartial( + MarkStarAction action, + List successEmailIds, + ) { + final currentEmailIdsPresentation = emailIdsPresentation; + emailIdsPresentation.value = action == MarkStarAction.markStar + ? currentEmailIdsPresentation.starByIds(successEmailIds) + : currentEmailIdsPresentation.unstarByIds(successEmailIds); + + final currentEmailsInThreadDetailInfo = emailsInThreadDetailInfo; + log('$runtimeType::_handleMarkPartial: currentEmailsInThreadDetailInfo = ${currentEmailsInThreadDetailInfo.length}'); + emailsInThreadDetailInfo.value = action == MarkStarAction.markStar + ? currentEmailsInThreadDetailInfo.starByEmailIds(successEmailIds) + : currentEmailsInThreadDetailInfo.unstarByEmailIds(successEmailIds); + } +} 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 53a13b82b8..eb68a8eab9 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 @@ -67,6 +67,7 @@ extension OnThreadDetailActionClick on ThreadDetailController { threadDetailActionType == EmailActionType.markAsStarred ? MarkStarAction.markStar : MarkStarAction.unMarkStar, + isThread: true, )); break; case EmailActionType.deletePermanently: diff --git a/lib/features/thread_detail/presentation/thread_detail_controller.dart b/lib/features/thread_detail/presentation/thread_detail_controller.dart index 9468fab0ee..b47c5afc2b 100644 --- a/lib/features/thread_detail/presentation/thread_detail_controller.dart +++ b/lib/features/thread_detail/presentation/thread_detail_controller.dart @@ -48,6 +48,7 @@ import 'package:tmail_ui_user/features/thread_detail/presentation/extension/hand import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_mark_multiple_emails_read_success.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_thread_action_success.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/mark_collapsed_email_unread_success.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/quick_create_rule_from_collapsed_email_success.dart'; @@ -227,6 +228,7 @@ class ThreadDetailController extends BaseController { void reset() { emailIdsPresentation.clear(); + emailsInThreadDetailInfo.clear(); scrollController?.dispose(); scrollController = null; currentExpandedEmailId.value = null; @@ -259,6 +261,9 @@ class ThreadDetailController extends BaseController { ); } else if (success is CreateNewRuleFilterSuccess) { quickCreateRuleFromCollapsedEmailSuccess(success); + } else if (success is MarkAsStarMultipleEmailAllSuccess || + success is MarkAsStarMultipleEmailHasSomeEmailFailure) { + handleMarkThreadAsStarredSuccess(success); } else { super.handleSuccessViewState(success); } diff --git a/model/lib/extensions/presentation_email_extension.dart b/model/lib/extensions/presentation_email_extension.dart index 404bc245b4..7ad9b0ea10 100644 --- a/model/lib/extensions/presentation_email_extension.dart +++ b/model/lib/extensions/presentation_email_extension.dart @@ -204,11 +204,6 @@ extension PresentationEmailExtension on PresentationEmail { MailboxId? get firstMailboxIdAvailable => mailboxIds?.entries.firstWhereOrNull((element) => element.value)?.key; - void resyncKeywords(Map newKeywords) { - keywords?.addAll(newKeywords); - keywords?.removeWhere((key, value) => !value); - } - bool get isDeletePermanentlyEnabled { return mailboxContain?.isTrash ?? mailboxContain?.isSpam ?? false; } From 2c10db2acd419566011eb6ee021da511e2af2c4b Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 12 Dec 2025 11:44:29 +0700 Subject: [PATCH 2/6] fixup! TF-4190 Performance issue to edit thread favorites --- .../extensions/email_extension.dart | 14 ++- .../extensions/email_loaded_extension.dart | 37 ++---- .../handle_email_action_extension.dart | 108 +++++++++--------- .../presentation_email_extension.dart | 21 ++-- .../extensions/map_keywords_extension.dart | 17 ++- ...email_in_thread_detail_info_extension.dart | 16 +++ ...email_in_thread_detail_info_extension.dart | 96 +++++----------- .../presentation_email_map_extension.dart | 92 +++------------ .../handle_thread_action_success.dart | 35 ++++-- 9 files changed, 179 insertions(+), 257 deletions(-) create mode 100644 lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart diff --git a/lib/features/email/presentation/extensions/email_extension.dart b/lib/features/email/presentation/extensions/email_extension.dart index 123e24c5af..ffa320fe99 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 isRemoved) { + return copyWith( + keywords: isRemoved + ? 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 index 9c27548d55..6587282ba0 100644 --- a/lib/features/email/presentation/extensions/email_loaded_extension.dart +++ b/lib/features/email/presentation/extensions/email_loaded_extension.dart @@ -1,38 +1,19 @@ import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; -import 'package:model/extensions/email_extension.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 starById(EmailId emailId) { - if (emailCurrent == null || emailCurrent!.id != emailId) { + EmailLoaded toggleEmailKeyword({ + required EmailId emailId, + required KeyWordIdentifier keyword, + required bool isRemoved, + }) { + if (emailCurrent == null || emailCurrent?.id != emailId) { return this; } - - final updatedKeywords = Map.from( - emailCurrent!.keywords ?? {}, - )..[KeyWordIdentifier.emailFlagged] = true; - - final updatedEmail = emailCurrent!.copyWith( - keywords: updatedKeywords, + return copyWith( + emailCurrent: emailCurrent?.toggleKeyword(keyword, isRemoved), ); - - return copyWith(emailCurrent: updatedEmail); - } - - EmailLoaded unstarById(EmailId emailId) { - if (emailCurrent == null || emailCurrent!.id != emailId) { - return this; - } - - final updatedKeywords = Map.from( - emailCurrent!.keywords ?? {}, - )..remove(KeyWordIdentifier.emailFlagged); - - final updatedEmail = emailCurrent!.copyWith( - keywords: updatedKeywords, - ); - - return copyWith(emailCurrent: updatedEmail); } } diff --git a/lib/features/email/presentation/extensions/handle_email_action_extension.dart b/lib/features/email/presentation/extensions/handle_email_action_extension.dart index 6725bf2efe..d72295feb9 100644 --- a/lib/features/email/presentation/extensions/handle_email_action_extension.dart +++ b/lib/features/email/presentation/extensions/handle_email_action_extension.dart @@ -1,4 +1,6 @@ import 'package:core/utils/platform_info.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/email/mark_star_action.dart'; import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_star_state.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; @@ -10,72 +12,70 @@ import 'package:tmail_ui_user/features/thread_detail/domain/extensions/presentat extension HandleEmailActionExtension on SingleEmailController { void markAsEmailStarSuccess(MarkAsStarEmailSuccess success) { - final isMark = success.markStarAction == MarkStarAction.markStar; - - if (PlatformInfo.isMobile && !isThreadDetailEnabled) { - _handleStarInMailboxContext(isMark); - } else { - _handleStarInThreadDetailContext(isMark); - } - toastManager.showMessageSuccess(success); - } - - void _handleStarInMailboxContext(bool isMark) { - final selectedEmail = mailboxDashBoardController.selectedEmail.value; - if (selectedEmail == null) return; - - final emailId = selectedEmail.id!; - final emailLoaded = currentEmailLoaded.value; - - // Update selected email - mailboxDashBoardController.selectedEmail.value = - isMark ? selectedEmail.star() : selectedEmail.unstar(); - // Update emailLoaded - if (emailLoaded != null) { - currentEmailLoaded.value = isMark - ? emailLoaded.starById(emailId) - : emailLoaded.unstarById(emailId); - } + _autoSyncStarToSelectedEmailOnMemory( + markStarAction: success.markStarAction, + emailId: success.emailId, + starKeyword: KeyWordIdentifier.emailFlagged, + ); + } - // Update flag to list emails in dashboard - mailboxDashBoardController.updateEmailFlagByEmailIds( - [emailId], - markStarAction: - isMark ? MarkStarAction.markStar : MarkStarAction.unMarkStar, + void _autoSyncStarToSelectedEmailOnMemory({ + required MarkStarAction markStarAction, + required EmailId emailId, + required KeyWordIdentifier starKeyword, + }) { + _updateStarInEmailOnMemory( + emailId: emailId, + starKeyword: starKeyword, + markStarAction: markStarAction, + isMobileThreadDisabled: PlatformInfo.isMobile && !isThreadDetailEnabled, ); } - void _handleStarInThreadDetailContext(bool isMark) { - if (threadDetailController == null) return; - final controller = threadDetailController!; - final currentEmailId = currentEmail?.id; - if (currentEmailId == null) return; + void _updateStarInEmailOnMemory({ + required MarkStarAction markStarAction, + required EmailId emailId, + required KeyWordIdentifier starKeyword, + required bool isMobileThreadDisabled, + }) { + final isRemoved = markStarAction == MarkStarAction.unMarkStar; - // Update list of ids - controller.emailIdsPresentation.value = isMark - ? controller.emailIdsPresentation.starOne(currentEmailId) - : controller.emailIdsPresentation.unstarOne(currentEmailId); + if (isMobileThreadDisabled) { + final selectedEmail = mailboxDashBoardController.selectedEmail.value; + if (selectedEmail?.id == emailId) { + mailboxDashBoardController.selectedEmail.value = + selectedEmail?.toggleKeyword(starKeyword, isRemoved); + } + } else { + final controller = threadDetailController; + if (controller != null) { + controller.emailIdsPresentation.value = + controller.emailIdsPresentation.toggleEmailKeywordById( + emailId: emailId, + keyword: starKeyword, + isRemoved: isRemoved, + ); - // Update thread detail email infos - controller.emailsInThreadDetailInfo.value = isMark - ? controller.emailsInThreadDetailInfo.starOne(currentEmailId) - : controller.emailsInThreadDetailInfo.unstarOne(currentEmailId); + controller.emailsInThreadDetailInfo.value = + controller.emailsInThreadDetailInfo.toggleEmailKeywordById( + emailId: emailId, keyword: starKeyword, isRemoved: isRemoved); + } + } - // Update loaded email - final emailLoaded = controller.currentEmailLoaded.value; - if (emailLoaded != null) { - controller.currentEmailLoaded.value = isMark - ? emailLoaded.starById(currentEmailId) - : emailLoaded.unstarById(currentEmailId); + final emailLoaded = currentEmailLoaded.value; + if (emailLoaded != null && emailLoaded.emailCurrent?.id == emailId) { + currentEmailLoaded.value = emailLoaded.toggleEmailKeyword( + emailId: emailId, + keyword: starKeyword, + isRemoved: isRemoved, + ); } - // Update flag to emails in dashboard mailboxDashBoardController.updateEmailFlagByEmailIds( - [currentEmailId], - markStarAction: - isMark ? MarkStarAction.markStar : MarkStarAction.unMarkStar, + [emailId], + markStarAction: markStarAction, ); } } diff --git a/lib/features/email/presentation/extensions/presentation_email_extension.dart b/lib/features/email/presentation/extensions/presentation_email_extension.dart index acb589a88e..23ecb77414 100644 --- a/lib/features/email/presentation/extensions/presentation_email_extension.dart +++ b/lib/features/email/presentation/extensions/presentation_email_extension.dart @@ -4,6 +4,7 @@ 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/map_keywords_extension.dart'; extension PresentationEmailExtension on PresentationEmail { ({ @@ -155,19 +156,11 @@ extension PresentationEmailExtension on PresentationEmail { ); } - PresentationEmail star() { - final updatedKeywords = Map.from( - keywords ?? {}, - )..[KeyWordIdentifier.emailFlagged] = true; - - return copyWith(keywords: updatedKeywords); - } - - PresentationEmail unstar() { - final updatedKeywords = Map.from( - keywords ?? {}, - )..remove(KeyWordIdentifier.emailFlagged); - - return copyWith(keywords: updatedKeywords); + PresentationEmail toggleKeyword(KeyWordIdentifier keyword, bool isRemoved) { + return copyWith( + keywords: isRemoved + ? keywords.withoutKeyword(keyword) + : keywords.withKeyword(keyword), + ); } } \ No newline at end of file diff --git a/lib/features/thread/data/extensions/map_keywords_extension.dart b/lib/features/thread/data/extensions/map_keywords_extension.dart index 3b6598c0a9..82d23e35cb 100644 --- a/lib/features/thread/data/extensions/map_keywords_extension.dart +++ b/lib/features/thread/data/extensions/map_keywords_extension.dart @@ -1,7 +1,16 @@ - import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; -extension MapKeywordsExtension on Map { +extension MapKeywordsExtension on Map? { + Map toMapString() => Map.fromIterables( + this?.keys.map((keyword) => keyword.value) ?? {}, + this?.values ?? [], + ); + + Map withKeyword(KeyWordIdentifier keyword) { + return Map.from(this ?? {})..[keyword] = true; + } - Map toMapString() => Map.fromIterables(keys.map((keyword) => keyword.value), values); -} \ No newline at end of file + Map withoutKeyword(KeyWordIdentifier keyword) { + return Map.from(this ?? {})..remove(keyword); + } +} diff --git a/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart new file mode 100644 index 0000000000..fc874742b8 --- /dev/null +++ b/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart @@ -0,0 +1,16 @@ +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/model/email_in_thread_detail_info.dart'; + +extension EmailInThreadDetailInfoExtension on EmailInThreadDetailInfo { + EmailInThreadDetailInfo toggleKeyword( + KeyWordIdentifier keyword, + bool isRemoved, + ) { + return copyWith( + keywords: isRemoved + ? keywords.withoutKeyword(keyword) + : keywords.withKeyword(keyword), + ); + } +} diff --git a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart index 54dd93b5b2..004be21e7d 100644 --- a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart +++ b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart @@ -1,88 +1,46 @@ 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/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/model/email_in_thread_detail_info.dart'; extension ListEmailInThreadDetailInfoExtension on List { List emailIdsToDisplay(bool isSentMailbox) => isSentMailbox - ? map((email) => email.emailId).toList() - : where((email) => email.isValidToDisplay) - .map((email) => email.emailId) + ? map((emailInfo) => emailInfo.emailId).toList() + : where((emailInfo) => emailInfo.isValidToDisplay) + .map((emailInfo) => emailInfo.emailId) .toList(); - List starAll() { - return map((email) { - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..[KeyWordIdentifier.emailFlagged] = true; - - return email.copyWith(keywords: updatedKeywords); - }).toList(); - } - - List unstarAll() { - return map((email) { - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..remove(KeyWordIdentifier.emailFlagged); - - return email.copyWith(keywords: updatedKeywords); - }).toList(); - } - - List starByEmailIds(List targetIds) { - final targetSet = targetIds.toSet(); - - return map((email) { - if (!targetSet.contains(email.emailId)) return email; - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..[KeyWordIdentifier.emailFlagged] = true; - - return email.copyWith(keywords: updatedKeywords); - }).toList(); + List toggleEmailKeywords({ + required KeyWordIdentifier keyword, + required bool isRemoved, + }) { + return map((emailInfo) => emailInfo.toggleKeyword(keyword, isRemoved)) + .toList(); } - List unstarByEmailIds(List targetIds) { + List toggleEmailKeywordByIds({ + required List targetIds, + required KeyWordIdentifier keyword, + required bool isRemoved, + }) { final targetSet = targetIds.toSet(); - - return map((email) { - if (!targetSet.contains(email.emailId)) return email; - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..remove(KeyWordIdentifier.emailFlagged); - - return email.copyWith(keywords: updatedKeywords); - }).toList(); - } - - List starOne(EmailId emailId) { - return map((email) { - if (email.emailId != emailId) { - return email; - } - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..[KeyWordIdentifier.emailFlagged] = true; - - return email.copyWith(keywords: updatedKeywords); + return map((emailInfo) { + if (!targetSet.contains(emailInfo.emailId)) return emailInfo; + return emailInfo.toggleKeyword(keyword, isRemoved); }).toList(); } - List unstarOne(EmailId emailId) { - return map((email) { - if (email.emailId != emailId) { - return email; + List toggleEmailKeywordById({ + required EmailId emailId, + required KeyWordIdentifier keyword, + required bool isRemoved, + }) { + return map((emailInfo) { + if (emailInfo.emailId != emailId) { + return emailInfo; } - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..remove(KeyWordIdentifier.emailFlagged); - - return email.copyWith(keywords: updatedKeywords); + return emailInfo.toggleKeyword(keyword, isRemoved); }).toList(); } } diff --git a/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart index 7beee964d0..1e4a83ef78 100644 --- a/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart +++ b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart @@ -1,110 +1,54 @@ 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/presentation_email.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart'; extension PresentationEmailMapExtension on Map { - Map starAll() { + Map toggleEmailKeywords({ + required KeyWordIdentifier keyword, + required bool isRemoved, + }) { return map((id, email) { if (email == null) { return MapEntry(id, email); } - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..[KeyWordIdentifier.emailFlagged] = true; - - return MapEntry( - id, - email.copyWith(keywords: updatedKeywords), - ); - }); - } - - Map unstarAll() { - return map((id, email) { - if (email == null) { - return MapEntry(id, email); - } - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..remove(KeyWordIdentifier.emailFlagged); - - return MapEntry( - id, - email.copyWith(keywords: updatedKeywords), - ); - }); - } - - Map starByIds(List ids) { - final targetSet = ids.toSet(); - - return map((id, email) { - if (!targetSet.contains(id) || email == null) { - return MapEntry(id, email); - } - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..[KeyWordIdentifier.emailFlagged] = true; - return MapEntry( id, - email.copyWith(keywords: updatedKeywords), + email.toggleKeyword(keyword, isRemoved), ); }); } - Map unstarByIds(List ids) { + Map toggleEmailKeywordByIds({ + required List ids, + required KeyWordIdentifier keyword, + required bool isRemoved, + }) { final targetSet = ids.toSet(); return map((id, email) { if (!targetSet.contains(id) || email == null) { return MapEntry(id, email); } - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..remove(KeyWordIdentifier.emailFlagged); - - return MapEntry( - id, - email.copyWith(keywords: updatedKeywords), - ); - }); - } - - Map starOne(EmailId emailId) { - return map((id, email) { - if (id != emailId || email == null) { - return MapEntry(id, email); - } - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..[KeyWordIdentifier.emailFlagged] = true; - return MapEntry( id, - email.copyWith(keywords: updatedKeywords), + email.toggleKeyword(keyword, isRemoved), ); }); } - Map unstarOne(EmailId emailId) { + Map toggleEmailKeywordById({ + required EmailId emailId, + required KeyWordIdentifier keyword, + required bool isRemoved, + }) { return map((id, email) { if (id != emailId || email == null) { return MapEntry(id, email); } - - final updatedKeywords = Map.from( - email.keywords ?? {}, - )..remove(KeyWordIdentifier.emailFlagged); - return MapEntry( id, - email.copyWith(keywords: updatedKeywords), + email.toggleKeyword(keyword, isRemoved), ); }); } diff --git a/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart b/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart index 4dba10739f..39e345f35a 100644 --- a/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart +++ b/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart @@ -2,6 +2,7 @@ import 'package:core/presentation/state/success.dart'; import 'package:core/utils/app_logger.dart'; import 'package:dartz/dartz.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/email/mark_star_action.dart'; import 'package:tmail_ui_user/features/thread/domain/state/mark_as_star_multiple_email_state.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart'; @@ -27,15 +28,19 @@ extension HandleThreadActionSuccess on ThreadDetailController { void _handleMarkAll(MarkStarAction action) { final currentEmailIdsPresentation = emailIdsPresentation; - emailIdsPresentation.value = action == MarkStarAction.markStar - ? currentEmailIdsPresentation.starAll() - : currentEmailIdsPresentation.unstarAll(); + emailIdsPresentation.value = + currentEmailIdsPresentation.toggleEmailKeywords( + keyword: KeyWordIdentifier.emailFlagged, + isRemoved: action == MarkStarAction.unMarkStar, + ); final currentEmailsInThreadDetailInfo = emailsInThreadDetailInfo; log('$runtimeType::_handleMarkAll: currentEmailsInThreadDetailInfo = ${currentEmailsInThreadDetailInfo.length}'); - emailsInThreadDetailInfo.value = action == MarkStarAction.markStar - ? currentEmailsInThreadDetailInfo.starAll() - : currentEmailsInThreadDetailInfo.unstarAll(); + emailsInThreadDetailInfo.value = + currentEmailsInThreadDetailInfo.toggleEmailKeywords( + keyword: KeyWordIdentifier.emailFlagged, + isRemoved: action == MarkStarAction.unMarkStar, + ); } void _handleMarkPartial( @@ -43,14 +48,20 @@ extension HandleThreadActionSuccess on ThreadDetailController { List successEmailIds, ) { final currentEmailIdsPresentation = emailIdsPresentation; - emailIdsPresentation.value = action == MarkStarAction.markStar - ? currentEmailIdsPresentation.starByIds(successEmailIds) - : currentEmailIdsPresentation.unstarByIds(successEmailIds); + emailIdsPresentation.value = + currentEmailIdsPresentation.toggleEmailKeywordByIds( + ids: successEmailIds, + keyword: KeyWordIdentifier.emailFlagged, + isRemoved: action == MarkStarAction.unMarkStar, + ); final currentEmailsInThreadDetailInfo = emailsInThreadDetailInfo; log('$runtimeType::_handleMarkPartial: currentEmailsInThreadDetailInfo = ${currentEmailsInThreadDetailInfo.length}'); - emailsInThreadDetailInfo.value = action == MarkStarAction.markStar - ? currentEmailsInThreadDetailInfo.starByEmailIds(successEmailIds) - : currentEmailsInThreadDetailInfo.unstarByEmailIds(successEmailIds); + emailsInThreadDetailInfo.value = + currentEmailsInThreadDetailInfo.toggleEmailKeywordByIds( + targetIds: successEmailIds, + keyword: KeyWordIdentifier.emailFlagged, + isRemoved: action == MarkStarAction.unMarkStar, + ); } } From d36977e44f463c96803a6a2ddb92bfe030e0ec5f Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 12 Dec 2025 11:46:22 +0700 Subject: [PATCH 3/6] TF-4190 Add unit test for MapKeywordsExtension --- .../map_keywords_extension_test.dart | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 test/features/thread/presentation/extensions/map_keywords_extension_test.dart diff --git a/test/features/thread/presentation/extensions/map_keywords_extension_test.dart b/test/features/thread/presentation/extensions/map_keywords_extension_test.dart new file mode 100644 index 0000000000..12844d4828 --- /dev/null +++ b/test/features/thread/presentation/extensions/map_keywords_extension_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart'; + +void main() { + group('MapKeywordsExtension - withKeyword()', () { + test('adds keyword to empty map', () { + final map = {}; + final keyword = KeyWordIdentifier("\$seen"); + + final result = map.withKeyword(keyword); + + expect(result.length, 1); + expect(result[keyword], true); + + // Ensure original map unchanged + expect(map.containsKey(keyword), false); + }); + + test('adds keyword to non-empty map', () { + final map = { + KeyWordIdentifier("\$flagged"): true, + }; + final keyword = KeyWordIdentifier("\$seen"); + + final result = map.withKeyword(keyword); + + expect(result.length, 2); + expect(result[keyword], true); + }); + + test('overwrites existing keyword value to true', () { + final keyword = KeyWordIdentifier("\$seen"); + + final map = { + keyword: false, + }; + + final result = map.withKeyword(keyword); + + expect(result.length, 1); + expect(result[keyword], true); + expect(map[keyword], false); // original map not mutated + }); + + test('does not mutate original map', () { + final map = { + KeyWordIdentifier("\$flagged"): false, + }; + final keyword = KeyWordIdentifier("\$flagged"); + + final result = map.withKeyword(keyword); + + expect(result[keyword], true); + expect(map[keyword], false); + }); + }); + + group('MapKeywordsExtension - withoutKeyword()', () { + test('removes existing keyword', () { + final keyword = KeyWordIdentifier("\$seen"); + + final map = { + keyword: true, + KeyWordIdentifier("\$flagged"): false, + }; + + final result = map.withoutKeyword(keyword); + + expect(result.containsKey(keyword), false); + expect(result.length, 1); + }); + + test('removing non-existing keyword keeps map unchanged', () { + final map = { + KeyWordIdentifier("\$flagged"): true, + }; + final keyword = KeyWordIdentifier("\$seen"); + + final result = map.withoutKeyword(keyword); + + expect(result.length, 1); + expect(result, map); // equal content + expect(identical(result, map), false); // but not the same instance + }); + + test('remove keyword from empty map returns empty map', () { + final map = {}; + final keyword = KeyWordIdentifier("\$junk"); + + final result = map.withoutKeyword(keyword); + + expect(result.isEmpty, true); + }); + + test('does not mutate original map', () { + final keyword = KeyWordIdentifier("\$seen"); + + final map = { + keyword: true, + }; + + final result = map.withoutKeyword(keyword); + + expect(result.containsKey(keyword), false); + expect(map.containsKey(keyword), true); // original not mutated + }); + }); +} From eb1ccf382f3c22b5f60362e344683bf6ad0f09eb Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 12 Dec 2025 12:15:20 +0700 Subject: [PATCH 4/6] fixup! TF-4190 Add unit test for MapKeywordsExtension --- .../extensions/email_loaded_extension.dart | 4 +- .../handle_email_action_extension.dart | 17 ++-- .../extensions/map_keywords_extension.dart | 4 +- ...email_in_thread_detail_info_extension.dart | 14 +-- .../presentation_email_map_extension.dart | 12 +-- .../handle_thread_action_success.dart | 69 ++++++++------ .../extensions/email_extension_test.dart | 90 +++++++++++++++++++ .../map_keywords_extension_test.dart | 57 +++++++++++- 8 files changed, 213 insertions(+), 54 deletions(-) diff --git a/lib/features/email/presentation/extensions/email_loaded_extension.dart b/lib/features/email/presentation/extensions/email_loaded_extension.dart index 6587282ba0..c1bd010d57 100644 --- a/lib/features/email/presentation/extensions/email_loaded_extension.dart +++ b/lib/features/email/presentation/extensions/email_loaded_extension.dart @@ -7,13 +7,13 @@ extension EmailLoadedExtension on EmailLoaded { EmailLoaded toggleEmailKeyword({ required EmailId emailId, required KeyWordIdentifier keyword, - required bool isRemoved, + required bool remove, }) { if (emailCurrent == null || emailCurrent?.id != emailId) { return this; } return copyWith( - emailCurrent: emailCurrent?.toggleKeyword(keyword, isRemoved), + emailCurrent: emailCurrent?.toggleKeyword(keyword, remove), ); } } diff --git a/lib/features/email/presentation/extensions/handle_email_action_extension.dart b/lib/features/email/presentation/extensions/handle_email_action_extension.dart index d72295feb9..2ea49ba6f0 100644 --- a/lib/features/email/presentation/extensions/handle_email_action_extension.dart +++ b/lib/features/email/presentation/extensions/handle_email_action_extension.dart @@ -12,13 +12,13 @@ import 'package:tmail_ui_user/features/thread_detail/domain/extensions/presentat extension HandleEmailActionExtension on SingleEmailController { void markAsEmailStarSuccess(MarkAsStarEmailSuccess success) { - toastManager.showMessageSuccess(success); - _autoSyncStarToSelectedEmailOnMemory( markStarAction: success.markStarAction, emailId: success.emailId, starKeyword: KeyWordIdentifier.emailFlagged, ); + + toastManager.showMessageSuccess(success); } void _autoSyncStarToSelectedEmailOnMemory({ @@ -40,13 +40,13 @@ extension HandleEmailActionExtension on SingleEmailController { required KeyWordIdentifier starKeyword, required bool isMobileThreadDisabled, }) { - final isRemoved = markStarAction == MarkStarAction.unMarkStar; + final remove = markStarAction == MarkStarAction.unMarkStar; if (isMobileThreadDisabled) { final selectedEmail = mailboxDashBoardController.selectedEmail.value; if (selectedEmail?.id == emailId) { mailboxDashBoardController.selectedEmail.value = - selectedEmail?.toggleKeyword(starKeyword, isRemoved); + selectedEmail?.toggleKeyword(starKeyword, remove); } } else { final controller = threadDetailController; @@ -55,12 +55,15 @@ extension HandleEmailActionExtension on SingleEmailController { controller.emailIdsPresentation.toggleEmailKeywordById( emailId: emailId, keyword: starKeyword, - isRemoved: isRemoved, + remove: remove, ); controller.emailsInThreadDetailInfo.value = controller.emailsInThreadDetailInfo.toggleEmailKeywordById( - emailId: emailId, keyword: starKeyword, isRemoved: isRemoved); + emailId: emailId, + keyword: starKeyword, + remove: remove, + ); } } @@ -69,7 +72,7 @@ extension HandleEmailActionExtension on SingleEmailController { currentEmailLoaded.value = emailLoaded.toggleEmailKeyword( emailId: emailId, keyword: starKeyword, - isRemoved: isRemoved, + remove: remove, ); } diff --git a/lib/features/thread/data/extensions/map_keywords_extension.dart b/lib/features/thread/data/extensions/map_keywords_extension.dart index 82d23e35cb..51a4171d93 100644 --- a/lib/features/thread/data/extensions/map_keywords_extension.dart +++ b/lib/features/thread/data/extensions/map_keywords_extension.dart @@ -2,8 +2,8 @@ import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; extension MapKeywordsExtension on Map? { Map toMapString() => Map.fromIterables( - this?.keys.map((keyword) => keyword.value) ?? {}, - this?.values ?? [], + this?.keys.map((keyword) => keyword.value) ?? const [], + this?.values ?? const [], ); Map withKeyword(KeyWordIdentifier keyword) { diff --git a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart index 004be21e7d..9df5f7351c 100644 --- a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart +++ b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart @@ -13,34 +13,36 @@ extension ListEmailInThreadDetailInfoExtension List toggleEmailKeywords({ required KeyWordIdentifier keyword, - required bool isRemoved, + required bool remove, }) { - return map((emailInfo) => emailInfo.toggleKeyword(keyword, isRemoved)) + return map((emailInfo) => emailInfo.toggleKeyword(keyword, remove)) .toList(); } List toggleEmailKeywordByIds({ required List targetIds, required KeyWordIdentifier keyword, - required bool isRemoved, + required bool remove, }) { + if (targetIds.isEmpty) return this; + final targetSet = targetIds.toSet(); return map((emailInfo) { if (!targetSet.contains(emailInfo.emailId)) return emailInfo; - return emailInfo.toggleKeyword(keyword, isRemoved); + return emailInfo.toggleKeyword(keyword, remove); }).toList(); } List toggleEmailKeywordById({ required EmailId emailId, required KeyWordIdentifier keyword, - required bool isRemoved, + required bool remove, }) { return map((emailInfo) { if (emailInfo.emailId != emailId) { return emailInfo; } - return emailInfo.toggleKeyword(keyword, isRemoved); + return emailInfo.toggleKeyword(keyword, remove); }).toList(); } } diff --git a/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart index 1e4a83ef78..8d0d7daf61 100644 --- a/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart +++ b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart @@ -6,7 +6,7 @@ import 'package:tmail_ui_user/features/email/presentation/extensions/presentatio extension PresentationEmailMapExtension on Map { Map toggleEmailKeywords({ required KeyWordIdentifier keyword, - required bool isRemoved, + required bool remove, }) { return map((id, email) { if (email == null) { @@ -14,7 +14,7 @@ extension PresentationEmailMapExtension on Map { } return MapEntry( id, - email.toggleKeyword(keyword, isRemoved), + email.toggleKeyword(keyword, remove), ); }); } @@ -22,7 +22,7 @@ extension PresentationEmailMapExtension on Map { Map toggleEmailKeywordByIds({ required List ids, required KeyWordIdentifier keyword, - required bool isRemoved, + required bool remove, }) { final targetSet = ids.toSet(); @@ -32,7 +32,7 @@ extension PresentationEmailMapExtension on Map { } return MapEntry( id, - email.toggleKeyword(keyword, isRemoved), + email.toggleKeyword(keyword, remove), ); }); } @@ -40,7 +40,7 @@ extension PresentationEmailMapExtension on Map { Map toggleEmailKeywordById({ required EmailId emailId, required KeyWordIdentifier keyword, - required bool isRemoved, + required bool remove, }) { return map((id, email) { if (id != emailId || email == null) { @@ -48,7 +48,7 @@ extension PresentationEmailMapExtension on Map { } return MapEntry( id, - email.toggleKeyword(keyword, isRemoved), + email.toggleKeyword(keyword, remove), ); }); } diff --git a/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart b/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart index 39e345f35a..6cf053e3ac 100644 --- a/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart +++ b/lib/features/thread_detail/presentation/extension/handle_thread_action_success.dart @@ -1,14 +1,23 @@ import 'package:core/presentation/state/success.dart'; -import 'package:core/utils/app_logger.dart'; import 'package:dartz/dartz.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/email/mark_star_action.dart'; +import 'package:model/email/presentation_email.dart'; import 'package:tmail_ui_user/features/thread/domain/state/mark_as_star_multiple_email_state.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/extensions/presentation_email_map_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/model/email_in_thread_detail_info.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; +typedef OnUpdateMapEmailIds = Map Function( + Map, +); + +typedef OnUpdateListEmailInfo = List Function( + List, +); + extension HandleThreadActionSuccess on ThreadDetailController { void handleMarkThreadAsStarredSuccess(Success success) { mailboxDashBoardController.consumeState(Stream.value(Right(success))); @@ -26,20 +35,25 @@ extension HandleThreadActionSuccess on ThreadDetailController { } } - void _handleMarkAll(MarkStarAction action) { - final currentEmailIdsPresentation = emailIdsPresentation; - emailIdsPresentation.value = - currentEmailIdsPresentation.toggleEmailKeywords( - keyword: KeyWordIdentifier.emailFlagged, - isRemoved: action == MarkStarAction.unMarkStar, - ); + void _updateEmailStates({ + required OnUpdateMapEmailIds updateIds, + required OnUpdateListEmailInfo updateDetails, + }) { + emailIdsPresentation.value = updateIds(emailIdsPresentation); + emailsInThreadDetailInfo.value = updateDetails(emailsInThreadDetailInfo); + } - final currentEmailsInThreadDetailInfo = emailsInThreadDetailInfo; - log('$runtimeType::_handleMarkAll: currentEmailsInThreadDetailInfo = ${currentEmailsInThreadDetailInfo.length}'); - emailsInThreadDetailInfo.value = - currentEmailsInThreadDetailInfo.toggleEmailKeywords( - keyword: KeyWordIdentifier.emailFlagged, - isRemoved: action == MarkStarAction.unMarkStar, + void _handleMarkAll(MarkStarAction action) { + final remove = action == MarkStarAction.unMarkStar; + _updateEmailStates( + updateIds: (current) => current.toggleEmailKeywords( + keyword: KeyWordIdentifier.emailFlagged, + remove: remove, + ), + updateDetails: (current) => current.toggleEmailKeywords( + keyword: KeyWordIdentifier.emailFlagged, + remove: remove, + ), ); } @@ -47,21 +61,18 @@ extension HandleThreadActionSuccess on ThreadDetailController { MarkStarAction action, List successEmailIds, ) { - final currentEmailIdsPresentation = emailIdsPresentation; - emailIdsPresentation.value = - currentEmailIdsPresentation.toggleEmailKeywordByIds( - ids: successEmailIds, - keyword: KeyWordIdentifier.emailFlagged, - isRemoved: action == MarkStarAction.unMarkStar, - ); - - final currentEmailsInThreadDetailInfo = emailsInThreadDetailInfo; - log('$runtimeType::_handleMarkPartial: currentEmailsInThreadDetailInfo = ${currentEmailsInThreadDetailInfo.length}'); - emailsInThreadDetailInfo.value = - currentEmailsInThreadDetailInfo.toggleEmailKeywordByIds( - targetIds: successEmailIds, - keyword: KeyWordIdentifier.emailFlagged, - isRemoved: action == MarkStarAction.unMarkStar, + final remove = action == MarkStarAction.unMarkStar; + _updateEmailStates( + updateIds: (current) => current.toggleEmailKeywordByIds( + ids: successEmailIds, + keyword: KeyWordIdentifier.emailFlagged, + remove: remove, + ), + updateDetails: (current) => current.toggleEmailKeywordByIds( + targetIds: successEmailIds, + keyword: KeyWordIdentifier.emailFlagged, + remove: remove, + ), ); } } diff --git a/test/features/email/presentation/extensions/email_extension_test.dart b/test/features/email/presentation/extensions/email_extension_test.dart index 7c6891c3c2..7e2d61dee4 100644 --- a/test/features/email/presentation/extensions/email_extension_test.dart +++ b/test/features/email/presentation/extensions/email_extension_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_header.dart'; import 'package:jmap_dart_client/jmap/mail/email/individual_header_identifier.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:model/email/email_property.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/smime_signature_status.dart'; @@ -276,4 +277,93 @@ void main() { expect(email.sMimeStatus, SMimeSignatureStatus.notSigned); }); }); + + group('EmailExtension.toggleKeyword', () { + final keyword = KeyWordIdentifier.emailSeen; + + test( + 'Email(keywords: null).toggleKeyword(x, false) ' + '=> keywords contains x:true', + () { + final email = Email(keywords: null); + + final result = email.toggleKeyword(keyword, false); + + expect(result.keywords, isNotNull); + expect(result.keywords!.length, 1); + expect(result.keywords![keyword], true); + }, + ); + + test( + 'Email(keywords: {x:true}).toggleKeyword(x, true) ' + '=> keywords does not contain x', + () { + final email = Email( + keywords: { + keyword: true, + }, + ); + + final result = email.toggleKeyword(keyword, true); + + expect(result.keywords, isNotNull); + expect(result.keywords!.containsKey(keyword), false); + }, + ); + + test( + 'Idempotency: remove keyword when already absent ' + '=> keywords unchanged', + () { + final email = Email( + keywords: { + KeyWordIdentifier.emailFlagged: true, + }, + ); + + final result = email.toggleKeyword(keyword, true); + + expect(result.keywords!.length, 1); + expect( + result.keywords!.containsKey(KeyWordIdentifier.emailFlagged), + true, + ); + expect(result.keywords!.containsKey(keyword), false); + }, + ); + + test( + 'Idempotency: add keyword when already present ' + '=> keyword remains true, no duplication', + () { + final email = Email( + keywords: { + keyword: true, + }, + ); + + final result = email.toggleKeyword(keyword, false); + + expect(result.keywords!.length, 1); + expect(result.keywords![keyword], true); + }, + ); + + test( + 'toggleKeyword returns new Email instance ' + '(immutability)', + () { + final email = Email( + keywords: { + keyword: true, + }, + ); + + final result = email.toggleKeyword(keyword, true); + + expect(identical(email, result), false); + }, + ); + }); } diff --git a/test/features/thread/presentation/extensions/map_keywords_extension_test.dart b/test/features/thread/presentation/extensions/map_keywords_extension_test.dart index 12844d4828..546eff254a 100644 --- a/test/features/thread/presentation/extensions/map_keywords_extension_test.dart +++ b/test/features/thread/presentation/extensions/map_keywords_extension_test.dart @@ -44,10 +44,10 @@ void main() { }); test('does not mutate original map', () { + final keyword = KeyWordIdentifier("\$flagged"); final map = { - KeyWordIdentifier("\$flagged"): false, + keyword: false, }; - final keyword = KeyWordIdentifier("\$flagged"); final result = map.withKeyword(keyword); @@ -106,4 +106,57 @@ void main() { expect(map.containsKey(keyword), true); // original not mutated }); }); + + group('MapKeywordsExtension – nullable receiver', () { + final keyword = KeyWordIdentifier.emailSeen; + + test( + 'null.withKeyword(x) returns new map containing x:true', + () { + Map? keywords; + + final result = keywords.withKeyword(keyword); + + expect(result, isNotNull); + expect(result.length, 1); + expect(result[keyword], true); + }, + ); + + test( + 'null.withoutKeyword(x) returns empty map (no crash)', + () { + Map? keywords; + + final result = keywords.withoutKeyword(keyword); + + expect(result, isNotNull); + expect(result.isEmpty, true); + }, + ); + + test( + 'null.withoutKeyword(x) is idempotent', + () { + Map? keywords; + + final result1 = keywords.withoutKeyword(keyword); + final result2 = result1.withoutKeyword(keyword); + + expect(result2.isEmpty, true); + }, + ); + + test( + 'null.withKeyword(x) followed by withoutKeyword(x) ' + 'returns empty map', + () { + Map? keywords; + + final result = keywords.withKeyword(keyword).withoutKeyword(keyword); + + expect(result.isEmpty, true); + }, + ); + }); } From 4f7c786e57a42d823628fad82e67e9036bdc40e1 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 15 Dec 2025 10:56:12 +0700 Subject: [PATCH 5/6] fixup! fixup! TF-4190 Add unit test for MapKeywordsExtension --- .../email/presentation/extensions/email_extension.dart | 7 +++++-- .../extensions/email_loaded_extension.dart | 5 ++++- .../extensions/handle_email_action_extension.dart | 2 +- .../extensions/presentation_email_extension.dart | 7 +++++-- .../email_in_thread_detail_info_extension.dart | 10 +++++----- .../list_email_in_thread_detail_info_extension.dart | 10 ++++++---- .../extensions/presentation_email_map_extension.dart | 6 +++--- .../presentation/extensions/email_extension_test.dart | 10 +++++----- 8 files changed, 34 insertions(+), 23 deletions(-) diff --git a/lib/features/email/presentation/extensions/email_extension.dart b/lib/features/email/presentation/extensions/email_extension.dart index ffa320fe99..60cdd8e02b 100644 --- a/lib/features/email/presentation/extensions/email_extension.dart +++ b/lib/features/email/presentation/extensions/email_extension.dart @@ -56,9 +56,12 @@ extension EmailExtension on Email { ) == true; } - Email toggleKeyword(KeyWordIdentifier keyword, bool isRemoved) { + Email toggleKeyword({ + required KeyWordIdentifier keyword, + required bool remove, + }) { return copyWith( - keywords: isRemoved + keywords: remove ? keywords.withoutKeyword(keyword) : keywords.withKeyword(keyword), ); diff --git a/lib/features/email/presentation/extensions/email_loaded_extension.dart b/lib/features/email/presentation/extensions/email_loaded_extension.dart index c1bd010d57..4aa6838ec8 100644 --- a/lib/features/email/presentation/extensions/email_loaded_extension.dart +++ b/lib/features/email/presentation/extensions/email_loaded_extension.dart @@ -13,7 +13,10 @@ extension EmailLoadedExtension on EmailLoaded { return this; } return copyWith( - emailCurrent: emailCurrent?.toggleKeyword(keyword, remove), + emailCurrent: emailCurrent?.toggleKeyword( + keyword: keyword, + remove: remove, + ), ); } } diff --git a/lib/features/email/presentation/extensions/handle_email_action_extension.dart b/lib/features/email/presentation/extensions/handle_email_action_extension.dart index 2ea49ba6f0..80985b6f4b 100644 --- a/lib/features/email/presentation/extensions/handle_email_action_extension.dart +++ b/lib/features/email/presentation/extensions/handle_email_action_extension.dart @@ -46,7 +46,7 @@ extension HandleEmailActionExtension on SingleEmailController { final selectedEmail = mailboxDashBoardController.selectedEmail.value; if (selectedEmail?.id == emailId) { mailboxDashBoardController.selectedEmail.value = - selectedEmail?.toggleKeyword(starKeyword, remove); + selectedEmail?.toggleKeyword(keyword: starKeyword, remove: remove); } } else { final controller = threadDetailController; diff --git a/lib/features/email/presentation/extensions/presentation_email_extension.dart b/lib/features/email/presentation/extensions/presentation_email_extension.dart index 23ecb77414..2085068061 100644 --- a/lib/features/email/presentation/extensions/presentation_email_extension.dart +++ b/lib/features/email/presentation/extensions/presentation_email_extension.dart @@ -156,9 +156,12 @@ extension PresentationEmailExtension on PresentationEmail { ); } - PresentationEmail toggleKeyword(KeyWordIdentifier keyword, bool isRemoved) { + PresentationEmail toggleKeyword({ + required KeyWordIdentifier keyword, + required bool remove, + }) { return copyWith( - keywords: isRemoved + keywords: remove ? keywords.withoutKeyword(keyword) : keywords.withKeyword(keyword), ); diff --git a/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart index fc874742b8..766b2ce183 100644 --- a/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart +++ b/lib/features/thread_detail/domain/extensions/email_in_thread_detail_info_extension.dart @@ -3,12 +3,12 @@ import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_exten import 'package:tmail_ui_user/features/thread_detail/domain/model/email_in_thread_detail_info.dart'; extension EmailInThreadDetailInfoExtension on EmailInThreadDetailInfo { - EmailInThreadDetailInfo toggleKeyword( - KeyWordIdentifier keyword, - bool isRemoved, - ) { + EmailInThreadDetailInfo toggleKeyword({ + required KeyWordIdentifier keyword, + required bool remove, + }) { return copyWith( - keywords: isRemoved + keywords: remove ? keywords.withoutKeyword(keyword) : keywords.withKeyword(keyword), ); diff --git a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart index 9df5f7351c..0d9ab1174d 100644 --- a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart +++ b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart @@ -15,8 +15,10 @@ extension ListEmailInThreadDetailInfoExtension required KeyWordIdentifier keyword, required bool remove, }) { - return map((emailInfo) => emailInfo.toggleKeyword(keyword, remove)) - .toList(); + return map((emailInfo) => emailInfo.toggleKeyword( + keyword: keyword, + remove: remove, + )).toList(); } List toggleEmailKeywordByIds({ @@ -29,7 +31,7 @@ extension ListEmailInThreadDetailInfoExtension final targetSet = targetIds.toSet(); return map((emailInfo) { if (!targetSet.contains(emailInfo.emailId)) return emailInfo; - return emailInfo.toggleKeyword(keyword, remove); + return emailInfo.toggleKeyword(keyword: keyword, remove: remove); }).toList(); } @@ -42,7 +44,7 @@ extension ListEmailInThreadDetailInfoExtension if (emailInfo.emailId != emailId) { return emailInfo; } - return emailInfo.toggleKeyword(keyword, remove); + return emailInfo.toggleKeyword(keyword: keyword, remove: remove); }).toList(); } } diff --git a/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart index 8d0d7daf61..7f55bc824e 100644 --- a/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart +++ b/lib/features/thread_detail/domain/extensions/presentation_email_map_extension.dart @@ -14,7 +14,7 @@ extension PresentationEmailMapExtension on Map { } return MapEntry( id, - email.toggleKeyword(keyword, remove), + email.toggleKeyword(keyword: keyword, remove: remove), ); }); } @@ -32,7 +32,7 @@ extension PresentationEmailMapExtension on Map { } return MapEntry( id, - email.toggleKeyword(keyword, remove), + email.toggleKeyword(keyword: keyword, remove: remove), ); }); } @@ -48,7 +48,7 @@ extension PresentationEmailMapExtension on Map { } return MapEntry( id, - email.toggleKeyword(keyword, remove), + email.toggleKeyword(keyword: keyword, remove: remove), ); }); } diff --git a/test/features/email/presentation/extensions/email_extension_test.dart b/test/features/email/presentation/extensions/email_extension_test.dart index 7e2d61dee4..0d9206445f 100644 --- a/test/features/email/presentation/extensions/email_extension_test.dart +++ b/test/features/email/presentation/extensions/email_extension_test.dart @@ -287,7 +287,7 @@ void main() { () { final email = Email(keywords: null); - final result = email.toggleKeyword(keyword, false); + final result = email.toggleKeyword(keyword: keyword, remove: false); expect(result.keywords, isNotNull); expect(result.keywords!.length, 1); @@ -305,7 +305,7 @@ void main() { }, ); - final result = email.toggleKeyword(keyword, true); + final result = email.toggleKeyword(keyword: keyword, remove: true); expect(result.keywords, isNotNull); expect(result.keywords!.containsKey(keyword), false); @@ -322,7 +322,7 @@ void main() { }, ); - final result = email.toggleKeyword(keyword, true); + final result = email.toggleKeyword(keyword: keyword, remove: true); expect(result.keywords!.length, 1); expect( @@ -343,7 +343,7 @@ void main() { }, ); - final result = email.toggleKeyword(keyword, false); + final result = email.toggleKeyword(keyword: keyword, remove: false); expect(result.keywords!.length, 1); expect(result.keywords![keyword], true); @@ -360,7 +360,7 @@ void main() { }, ); - final result = email.toggleKeyword(keyword, true); + final result = email.toggleKeyword(keyword: keyword, remove: true); expect(identical(email, result), false); }, From 35f07f96f3f67cd5a10b641e94f1a1762634d1c2 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 15 Dec 2025 15:17:33 +0700 Subject: [PATCH 6/6] fixup! fixup! fixup! TF-4190 Add unit test for MapKeywordsExtension --- .../extensions/email_loaded_extension.dart | 4 ++-- ...email_in_thread_detail_info_extension.dart | 23 ++++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/features/email/presentation/extensions/email_loaded_extension.dart b/lib/features/email/presentation/extensions/email_loaded_extension.dart index 4aa6838ec8..3e461f31b1 100644 --- a/lib/features/email/presentation/extensions/email_loaded_extension.dart +++ b/lib/features/email/presentation/extensions/email_loaded_extension.dart @@ -9,11 +9,11 @@ extension EmailLoadedExtension on EmailLoaded { required KeyWordIdentifier keyword, required bool remove, }) { - if (emailCurrent == null || emailCurrent?.id != emailId) { + if (emailCurrent == null || emailCurrent!.id != emailId) { return this; } return copyWith( - emailCurrent: emailCurrent?.toggleKeyword( + emailCurrent: emailCurrent!.toggleKeyword( keyword: keyword, remove: remove, ), diff --git a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart index 0d9ab1174d..b72789e6f8 100644 --- a/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart +++ b/lib/features/thread_detail/domain/extensions/list_email_in_thread_detail_info_extension.dart @@ -26,7 +26,10 @@ extension ListEmailInThreadDetailInfoExtension required KeyWordIdentifier keyword, required bool remove, }) { - if (targetIds.isEmpty) return this; + // Always return a new list to keep consistent semantics + if (targetIds.isEmpty) { + return toList(); + } final targetSet = targetIds.toSet(); return map((emailInfo) { @@ -40,11 +43,19 @@ extension ListEmailInThreadDetailInfoExtension required KeyWordIdentifier keyword, required bool remove, }) { - return map((emailInfo) { - if (emailInfo.emailId != emailId) { - return emailInfo; + final result = toList(); // ensure new list instance + + for (var i = 0; i < result.length; i++) { + final emailInfo = result[i]; + if (emailInfo.emailId == emailId) { + result[i] = emailInfo.toggleKeyword( + keyword: keyword, + remove: remove, + ); + break; // stop once found } - return emailInfo.toggleKeyword(keyword: keyword, remove: remove); - }).toList(); + } + + return result; } }