From 0e49c18f9d068de7fb41c00d10a4dc0fdace57a2 Mon Sep 17 00:00:00 2001 From: dab246 Date: Tue, 16 Dec 2025 15:24:38 +0700 Subject: [PATCH] TF-4141 Add `Action required` folder to left menu --- assets/images/ic_mailbox_action_required.svg | 5 + .../presentation/resources/image_paths.dart | 1 + .../handle_action_required_tab_extension.dart | 91 +++++++++ .../list_mailbox_node_extension.dart | 62 +++++- .../presentation_mailbox_extension.dart | 8 + .../presentation/mailbox_controller.dart | 9 + .../action_required_interactor_bindings.dart | 79 +++++++ .../bindings/mailbox_dashboard_bindings.dart | 3 + .../action_required_folder_controller.dart | 82 ++++++++ .../mailbox_dashboard_controller.dart | 7 + .../search_mailbox_controller.dart | 7 + .../repository/thread_repository_impl.dart | 19 ++ .../domain/exceptions/thread_exceptions.dart | 3 +- .../domain/repository/thread_repository.dart | 5 + .../get_count_emails_in_folder_state.dart | 18 ++ ...nt_unread_emails_in_folder_interactor.dart | 29 +++ lib/l10n/intl_messages.arb | 8 +- lib/main/localizations/app_localizations.dart | 7 + model/lib/mailbox/presentation_mailbox.dart | 7 + .../list_mailbox_node_extension_test.dart | 192 ++++++++++++++++++ .../mailbox_dashboard_controller_test.dart | 4 + .../mailbox_dashboard_view_widget_test.dart | 4 + ...fore_time_in_search_email_filter_test.dart | 4 + 23 files changed, 641 insertions(+), 13 deletions(-) create mode 100644 assets/images/ic_mailbox_action_required.svg create mode 100644 lib/features/mailbox/presentation/extensions/handle_action_required_tab_extension.dart create mode 100644 lib/features/mailbox_dashboard/presentation/bindings/action_required_interactor_bindings.dart create mode 100644 lib/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart create mode 100644 lib/features/thread/domain/state/get_count_emails_in_folder_state.dart create mode 100644 lib/features/thread/domain/usecases/get_count_unread_emails_in_folder_interactor.dart diff --git a/assets/images/ic_mailbox_action_required.svg b/assets/images/ic_mailbox_action_required.svg new file mode 100644 index 000000000..0b67f0ee0 --- /dev/null +++ b/assets/images/ic_mailbox_action_required.svg @@ -0,0 +1,5 @@ + + + diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index 7527c5610..4608b9ace 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -45,6 +45,7 @@ class ImagePaths { String get icMailboxSpam => _getImagePath('ic_mailbox_spam.svg'); String get icMailboxTrash => _getImagePath('ic_mailbox_trash.svg'); String get icMailboxFavorite => _getImagePath('ic_mailbox_favorite.svg'); + String get icMailboxActionRequired => _getImagePath('ic_mailbox_action_required.svg'); String get icFilterSelected => _getImagePath('ic_filter_selected.svg'); String get icFilterMessageAll => _getImagePath('ic_filter_message_all.svg'); String get icFilterMessageAttachments => _getImagePath('ic_filter_message_attachments.svg'); diff --git a/lib/features/mailbox/presentation/extensions/handle_action_required_tab_extension.dart b/lib/features/mailbox/presentation/extensions/handle_action_required_tab_extension.dart new file mode 100644 index 000000000..40d08bacc --- /dev/null +++ b/lib/features/mailbox/presentation/extensions/handle_action_required_tab_extension.dart @@ -0,0 +1,91 @@ +import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:tmail_ui_user/features/base/base_mailbox_controller.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_node.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_ai_action_extension.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; + +extension HandleActionRequiredTabExtension on BaseMailboxController { + void setUpActionRequiredFolder( + MailboxDashBoardController dashboardController, + ) { + if (!dashboardController.isAiCapabilitySupported) return; + + dashboardController.actionRequiredFolderController.getCountEmails( + session: dashboardController.sessionCurrent, + accountId: dashboardController.accountId.value, + ); + } + + void onActionRequiredFolderCountChanged(int count) { + if (count > 0) { + _addActionRequiredFolder(count); + } else { + _removeActionRequiredFolder(); + } + } + + void _addActionRequiredFolder(int count) { + final folder = _buildActionRequiredFolder(count); + + _addToDefaultMailboxTree(folder); + _addToAllMailboxes(folder); + } + + PresentationMailbox _buildActionRequiredFolder(int count) { + final base = PresentationMailbox.actionRequiredFolder; + + return base.copyWith( + displayName: + currentContext != null ? base.getDisplayName(currentContext!) : null, + totalEmails: TotalEmails(UnsignedInt(count)), + unreadEmails: UnreadEmails(UnsignedInt(count)), + ); + } + + void _addToDefaultMailboxTree(PresentationMailbox folder) { + final root = defaultMailboxTree.value.root; + final children = List.from(root.childrenItems ?? []); + + children.insertAfterStarredOrInbox(MailboxNode(folder)); + + defaultMailboxTree.value = MailboxTree( + root.copyWith(children: children), + ); + } + + void _addToAllMailboxes(PresentationMailbox folder) { + if (_allMailboxesContains(folder.id)) return; + allMailboxes.add(folder); + } + + void _removeActionRequiredFolder() { + final folder = PresentationMailbox.actionRequiredFolder; + + _removeFromDefaultMailboxTree(folder.id); + _removeFromAllMailboxes(folder.id); + } + + void _removeFromDefaultMailboxTree(MailboxId folderId) { + final root = defaultMailboxTree.value.root; + final children = List.from(root.childrenItems ?? []) + ..removeWhere((node) => node.item.id == folderId); + + defaultMailboxTree.value = MailboxTree( + root.copyWith(children: children), + ); + } + + void _removeFromAllMailboxes(MailboxId folderId) { + allMailboxes.removeWhere((mailbox) => mailbox.id == folderId); + } + + bool _allMailboxesContains(MailboxId id) { + return allMailboxes.any((mailbox) => mailbox.id == id); + } +} diff --git a/lib/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart b/lib/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart index 8e34e9096..98d2a3ff8 100644 --- a/lib/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart +++ b/lib/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart @@ -5,20 +5,60 @@ import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_node.d extension ListMailboxNodeExtension on List { List get mailboxIds => map((node) => node.item.id).toList(); + /// Insert [newNode] after Inbox if present, otherwise at the beginning. void insertAfterInbox(MailboxNode newNode) { - final alreadyExists = any((node) => node.item.id == newNode.item.id); - if (alreadyExists) return; - - final index = indexWhere( - (node) => - node.item.role?.value == PresentationMailbox.inboxRole || - node.item.name?.name.toLowerCase() == 'inbox', + insertAfterByPriority( + newNode, + [_isInbox], ); + } - if (index != -1) { - insert(index + 1, newNode); - } else { - insert(0, newNode); + /// Insert [newNode] after Starred if present, + /// otherwise after Inbox, otherwise at the beginning. + void insertAfterStarredOrInbox(MailboxNode newNode) { + insertAfterByPriority( + newNode, + [ + _isStarred, + _isInbox, + ], + ); + } + + /// Insert [newNode] after the first mailbox matching [priorities]. + /// If none match, inserts at the beginning. + void insertAfterByPriority( + MailboxNode newNode, + List priorities, + ) { + if (_containsMailbox(newNode)) return; + + for (final predicate in priorities) { + final index = indexWhere(predicate); + if (index != -1) { + insert(index + 1, newNode); + return; + } } + + insert(0, newNode); + } + + bool _isInbox(MailboxNode node) { + return node.item.role?.value == PresentationMailbox.inboxRole || + _equalsIgnoreCase(node.item.name?.name, 'inbox'); + } + + bool _isStarred(MailboxNode node) { + return node.item.role?.value == PresentationMailbox.favoriteRole || + _equalsIgnoreCase(node.item.name?.name, 'starred'); + } + + bool _containsMailbox(MailboxNode node) { + return any((e) => e.item.id == node.item.id); + } + + bool _equalsIgnoreCase(String? value, String expected) { + return value != null && value.toLowerCase() == expected; } } diff --git a/lib/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart b/lib/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart index c91f17709..5798c6716 100644 --- a/lib/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart +++ b/lib/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart @@ -17,6 +17,8 @@ extension PresentationMailboxExtension on PresentationMailbox { return AppLocalizations.of(context).inboxMailboxDisplayName; case PresentationMailbox.favoriteRole: return AppLocalizations.of(context).favoriteMailboxDisplayName; + case PresentationMailbox.actionRequiredRole: + return AppLocalizations.of(context).actionRequiredMailboxDisplayName; case PresentationMailbox.archiveRole: return AppLocalizations.of(context).archiveMailboxDisplayName; case PresentationMailbox.draftsRole: @@ -44,6 +46,10 @@ extension PresentationMailboxExtension on PresentationMailbox { switch(role!.value.toLowerCase()) { case PresentationMailbox.inboxRole: return appLocalizations.inboxMailboxDisplayName; + case PresentationMailbox.favoriteRole: + return appLocalizations.favoriteMailboxDisplayName; + case PresentationMailbox.actionRequiredRole: + return appLocalizations.actionRequiredMailboxDisplayName; case PresentationMailbox.archiveRole: return appLocalizations.archiveMailboxDisplayName; case PresentationMailbox.draftsRole: @@ -73,6 +79,8 @@ extension PresentationMailboxExtension on PresentationMailbox { return imagePaths.icMailboxInbox; case PresentationMailbox.favoriteRole: return imagePaths.icMailboxFavorite; + case PresentationMailbox.actionRequiredRole: + return imagePaths.icMailboxActionRequired; case PresentationMailbox.draftsRole: return imagePaths.icMailboxDrafts; case PresentationMailbox.outboxRole: diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index b60fc7167..5af64b502 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -69,6 +69,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_int import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/action/mailbox_ui_action.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/extensions/handle_action_required_tab_extension.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/extensions/handle_favorite_tab_extension.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart'; @@ -264,6 +265,7 @@ class MailboxController extends BaseMailboxController (failure) { if (failure is GetAllMailboxFailure) { addFavoriteFolderToMailboxList(); + setUpActionRequiredFolder(mailboxDashBoardController); mailboxDashBoardController.updateRefreshAllMailboxState(Left(RefreshAllMailboxFailure())); showRetryToast(failure); } @@ -271,6 +273,7 @@ class MailboxController extends BaseMailboxController (success) { if (success is GetAllMailboxSuccess) { addFavoriteFolderToMailboxList(); + setUpActionRequiredFolder(mailboxDashBoardController); mailboxDashBoardController.updateRefreshAllMailboxState(Right(RefreshAllMailboxSuccess())); _handleCreateDefaultFolderIfMissing(mailboxDashBoardController.mapDefaultMailboxIdByRole); _handleDataFromNavigationRouter(); @@ -280,6 +283,7 @@ class MailboxController extends BaseMailboxController } } else if (success is CreateDefaultMailboxAllSuccess) { addFavoriteFolderToMailboxList(); + setUpActionRequiredFolder(mailboxDashBoardController); } }); } @@ -434,6 +438,11 @@ class MailboxController extends BaseMailboxController ); } }); + + ever( + mailboxDashBoardController.actionRequiredFolderController.actionRequiredFolderCount, + onActionRequiredFolderCountChanged, + ); } void _handleMarkEmailsAsReadOrUnread({ diff --git a/lib/features/mailbox_dashboard/presentation/bindings/action_required_interactor_bindings.dart b/lib/features/mailbox_dashboard/presentation/bindings/action_required_interactor_bindings.dart new file mode 100644 index 000000000..bf5aa08e0 --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/bindings/action_required_interactor_bindings.dart @@ -0,0 +1,79 @@ +import 'package:core/data/model/source_type/data_source_type.dart'; +import 'package:get/get.dart'; +import 'package:tmail_ui_user/features/base/interactors_bindings.dart'; +import 'package:tmail_ui_user/features/caching/caching_manager.dart'; +import 'package:tmail_ui_user/features/mailbox/data/datasource/state_datasource.dart'; +import 'package:tmail_ui_user/features/mailbox/data/datasource_impl/state_datasource_impl.dart'; +import 'package:tmail_ui_user/features/mailbox/data/local/state_cache_manager.dart'; +import 'package:tmail_ui_user/features/thread/data/datasource/thread_datasource.dart'; +import 'package:tmail_ui_user/features/thread/data/datasource_impl/local_thread_datasource_impl.dart'; +import 'package:tmail_ui_user/features/thread/data/datasource_impl/thread_datasource_impl.dart'; +import 'package:tmail_ui_user/features/thread/data/local/email_cache_manager.dart'; +import 'package:tmail_ui_user/features/thread/data/network/thread_api.dart'; +import 'package:tmail_ui_user/features/thread/data/network/thread_isolate_worker.dart'; +import 'package:tmail_ui_user/features/thread/data/repository/thread_repository_impl.dart'; +import 'package:tmail_ui_user/features/thread/domain/repository/thread_repository.dart'; +import 'package:tmail_ui_user/features/thread/domain/usecases/get_count_unread_emails_in_folder_interactor.dart'; +import 'package:tmail_ui_user/main/exceptions/cache_exception_thrower.dart'; +import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart'; +import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart'; + +class ActionRequiredInteractorBindings extends InteractorsBindings { + @override + void bindingsDataSource() { + Get.lazyPut(() => Get.find()); + Get.lazyPut(() => Get.find()); + } + + @override + void bindingsDataSourceImpl() { + Get.lazyPut( + () => ThreadDataSourceImpl( + Get.find(), + Get.find(), + Get.find(), + ), + ); + Get.lazyPut( + () => LocalThreadDataSourceImpl( + Get.find(), + Get.find(), + Get.find(), + ), + ); + Get.lazyPut( + () => StateDataSourceImpl( + Get.find(), + Get.find(), + Get.find(), + ), + ); + } + + @override + void bindingsInteractor() { + Get.lazyPut( + () => GetCountUnreadEmailsInFolderInteractor( + Get.find(), + ), + ); + } + + @override + void bindingsRepository() { + Get.lazyPut(() => Get.find()); + } + + @override + void bindingsRepositoryImpl() { + Get.lazyPut( + () => ThreadRepositoryImpl( + { + DataSourceType.network: Get.find(), + DataSourceType.local: Get.find() + }, + Get.find(), + ), + ); + } +} diff --git a/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart b/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart index c4886dd99..c8b1fab2a 100644 --- a/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart +++ b/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart @@ -106,6 +106,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/store_e import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/store_last_time_dismissed_spam_reported_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/store_spam_report_state_interactor.dart'; import 'package:tmail_ui_user/features/download/presentation/bindings/download_interactor_bindings.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/advanced_filter_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/app_grid_dashboard_controller.dart'; import 'package:tmail_ui_user/features/download/presentation/controllers/download_controller.dart'; @@ -209,6 +210,8 @@ class MailboxDashBoardBindings extends BaseBindings { Get.lazyPut(() => ComposerManager()); + Get.put(ActionRequiredFolderController()); + Get.put(MailboxDashBoardController( Get.find(), Get.find(), diff --git a/lib/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart new file mode 100644 index 000000000..e48d7341f --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart @@ -0,0 +1,82 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:tmail_ui_user/features/base/base_controller.dart'; +import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/bindings/action_required_interactor_bindings.dart'; +import 'package:tmail_ui_user/features/thread/domain/exceptions/thread_exceptions.dart'; +import 'package:tmail_ui_user/features/thread/domain/state/get_count_emails_in_folder_state.dart'; +import 'package:tmail_ui_user/features/thread/domain/usecases/get_count_unread_emails_in_folder_interactor.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; + +class ActionRequiredFolderController extends BaseController { + final actionRequiredFolderCount = RxInt(0); + + GetCountUnreadEmailsInFolderInteractor? + _getCountUnreadEmailsInFolderInteractor; + + void injectBinding() { + ActionRequiredInteractorBindings().dependencies(); + + _getCountUnreadEmailsInFolderInteractor = + getBinding(); + } + + Future getCountEmails({ + required Session? session, + required AccountId? accountId, + }) async { + if (_getCountUnreadEmailsInFolderInteractor == null) { + consumeState(Stream.value( + Left(GetCountUnreadEmailsInFolderFailure(InteractorIsNullException())), + )); + return; + } + + if (session == null) { + consumeState(Stream.value( + Left(GetCountUnreadEmailsInFolderFailure(NotFoundSessionException())), + )); + return; + } + + if (accountId == null) { + consumeState(Stream.value( + Left(GetCountUnreadEmailsInFolderFailure(NotFoundAccountIdException())), + )); + return; + } + + consumeState(_getCountUnreadEmailsInFolderInteractor!.execute( + session: session, + accountId: accountId, + )); + } + + @override + void handleSuccessViewState(Success success) { + if (success is GetCountUnreadEmailsInFolderSuccess) { + actionRequiredFolderCount.value = success.count; + } else { + super.handleSuccessViewState(success); + } + } + + @override + void handleFailureViewState(Failure failure) { + if (failure is GetCountUnreadEmailsInFolderFailure) { + actionRequiredFolderCount.value = 0; + } else { + super.handleFailureViewState(failure); + } + } + + @override + void onClose() { + _getCountUnreadEmailsInFolderInteractor = null; + super.onClose(); + } +} 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 cb81c1890..79b7f41b0 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -117,6 +117,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/save_te import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/store_email_sort_order_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/dashboard_action.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/download_ui_action.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/app_grid_dashboard_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/search_controller.dart' as search; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/spam_report_controller.dart'; @@ -124,6 +125,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/cleanup_recent_search_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/delete_emails_in_mailbox_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_action_type_for_email_selection.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_ai_action_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_clear_mailbox_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_create_new_rule_filter.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_preferences_setting_extension.dart'; @@ -237,6 +239,8 @@ class MailboxDashBoardController extends ReloadableController final getStoredOidcConfigurationInteractor = Get.find(); final getTokenOIDCInteractor = Get.find(); + final actionRequiredFolderController = + Get.find(); final MoveToMailboxInteractor _moveToMailboxInteractor; final DeleteEmailPermanentlyInteractor _deleteEmailPermanentlyInteractor; @@ -874,6 +878,9 @@ class MailboxDashBoardController extends ReloadableController if (PlatformInfo.isMobile) { injectFCMBindings(session, currentAccountId); } + if (isAiCapabilitySupported) { + actionRequiredFolderController.injectBinding(); + } _getVacationResponse(); spamReportController.getSpamReportStateAction(); diff --git a/lib/features/search/mailbox/presentation/search_mailbox_controller.dart b/lib/features/search/mailbox/presentation/search_mailbox_controller.dart index d6f0a8280..474642d06 100644 --- a/lib/features/search/mailbox/presentation/search_mailbox_controller.dart +++ b/lib/features/search/mailbox/presentation/search_mailbox_controller.dart @@ -58,6 +58,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_int import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/action/mailbox_ui_action.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/extensions/handle_action_required_tab_extension.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/extensions/handle_favorite_tab_extension.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_actions.dart'; @@ -253,6 +254,12 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa ); } }); + + ever( + dashboardController + .actionRequiredFolderController.actionRequiredFolderCount, + onActionRequiredFolderCountChanged, + ); } void _registerInputFocusListener() { diff --git a/lib/features/thread/data/repository/thread_repository_impl.dart b/lib/features/thread/data/repository/thread_repository_impl.dart index f85eec412..3052a5d47 100644 --- a/lib/features/thread/data/repository/thread_repository_impl.dart +++ b/lib/features/thread/data/repository/thread_repository_impl.dart @@ -16,6 +16,7 @@ import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; import 'package:jmap_dart_client/jmap/core/user_name.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_filter_condition.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/model.dart'; import 'package:tmail_ui_user/features/mailbox/data/datasource/state_datasource.dart'; @@ -523,4 +524,22 @@ class ThreadRepositoryImpl extends ThreadRepository { ); yield emailResponse; } + + @override + Future getCountUnreadEmailsInFolder({ + required Session session, + required AccountId accountId, + }) async { + final networkDataSource = mapDataSource[DataSourceType.network]!; + final emailResponse = await networkDataSource.getAllEmail( + session, + accountId, + filter: EmailFilterCondition( + hasKeyword: KeyWordIdentifierExtension.needActionMail.value, + notKeyword: KeyWordIdentifier.emailSeen.value, + ), + properties: Properties({EmailProperty.id}), + ); + return emailResponse.emailList?.length ?? 0; + } } \ No newline at end of file diff --git a/lib/features/thread/domain/exceptions/thread_exceptions.dart b/lib/features/thread/domain/exceptions/thread_exceptions.dart index 134c9ce63..8941b507a 100644 --- a/lib/features/thread/domain/exceptions/thread_exceptions.dart +++ b/lib/features/thread/domain/exceptions/thread_exceptions.dart @@ -1,2 +1,3 @@ +class NotFoundEmailsDeletedException implements Exception {} -class NotFoundEmailsDeletedException implements Exception {} \ No newline at end of file +class InteractorIsNullException implements Exception {} \ No newline at end of file diff --git a/lib/features/thread/domain/repository/thread_repository.dart b/lib/features/thread/domain/repository/thread_repository.dart index 686b46c0a..35005de71 100644 --- a/lib/features/thread/domain/repository/thread_repository.dart +++ b/lib/features/thread/domain/repository/thread_repository.dart @@ -93,4 +93,9 @@ abstract class ThreadRepository { ); Future clearEmailCacheAndStateCache(AccountId accountId, Session session); + + Future getCountUnreadEmailsInFolder({ + required Session session, + required AccountId accountId, + }); } \ No newline at end of file diff --git a/lib/features/thread/domain/state/get_count_emails_in_folder_state.dart b/lib/features/thread/domain/state/get_count_emails_in_folder_state.dart new file mode 100644 index 000000000..a5b9420b4 --- /dev/null +++ b/lib/features/thread/domain/state/get_count_emails_in_folder_state.dart @@ -0,0 +1,18 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; + +class GettingCountUnreadEmailsInFolder extends LoadingState {} + +class GetCountUnreadEmailsInFolderSuccess extends UIState { + final int count; + + GetCountUnreadEmailsInFolderSuccess({required this.count}); + + @override + List get props => [count]; +} + +class GetCountUnreadEmailsInFolderFailure extends FeatureFailure { + GetCountUnreadEmailsInFolderFailure(dynamic exception) + : super(exception: exception); +} diff --git a/lib/features/thread/domain/usecases/get_count_unread_emails_in_folder_interactor.dart b/lib/features/thread/domain/usecases/get_count_unread_emails_in_folder_interactor.dart new file mode 100644 index 000000000..00dc9b462 --- /dev/null +++ b/lib/features/thread/domain/usecases/get_count_unread_emails_in_folder_interactor.dart @@ -0,0 +1,29 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:tmail_ui_user/features/thread/domain/repository/thread_repository.dart'; +import 'package:tmail_ui_user/features/thread/domain/state/get_count_emails_in_folder_state.dart'; + +class GetCountUnreadEmailsInFolderInteractor { + final ThreadRepository _threadRepository; + + GetCountUnreadEmailsInFolderInteractor(this._threadRepository); + + Stream> execute({ + required Session session, + required AccountId accountId, + }) async* { + try { + yield Right(GettingCountUnreadEmailsInFolder()); + final count = await _threadRepository.getCountUnreadEmailsInFolder( + session: session, + accountId: accountId, + ); + yield Right(GetCountUnreadEmailsInFolderSuccess(count: count)); + } catch (e) { + yield Left(GetCountUnreadEmailsInFolderFailure(e)); + } + } +} diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index cced4a5d6..192bcf0ac 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2025-12-16T11:52:06.406969", + "@@last_modified": "2025-12-16T15:21:10.099014", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -5141,5 +5141,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "actionRequiredMailboxDisplayName": "Action required", + "@actionRequiredMailboxDisplayName": { + "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 65d7abba3..547fab260 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -5464,4 +5464,11 @@ class AppLocalizations { name: 'actionRequired', ); } + + String get actionRequiredMailboxDisplayName { + return Intl.message( + 'Action required', + name: 'actionRequiredMailboxDisplayName', + ); + } } diff --git a/model/lib/mailbox/presentation_mailbox.dart b/model/lib/mailbox/presentation_mailbox.dart index f779a401b..d8052fa61 100644 --- a/model/lib/mailbox/presentation_mailbox.dart +++ b/model/lib/mailbox/presentation_mailbox.dart @@ -19,6 +19,7 @@ class PresentationMailbox with EquatableMixin { static const String archiveRole = 'archive'; static const String recoveredRole = 'restored messages'; static const String favoriteRole = 'favorite'; + static const String actionRequiredRole = 'need-action'; static final PresentationMailbox unifiedMailbox = PresentationMailbox(MailboxId(Id('unified'))); static final PresentationMailbox favoriteFolder = PresentationMailbox( @@ -26,6 +27,11 @@ class PresentationMailbox with EquatableMixin { name: MailboxName('Starred'), role: roleFavorite, ); + static final PresentationMailbox actionRequiredFolder = PresentationMailbox( + MailboxId(Id(actionRequiredRole)), + name: MailboxName('Action required'), + role: roleActionRequired, + ); static final roleInbox = Role(inboxRole); static final roleTrash = Role(trashRole); @@ -38,6 +44,7 @@ class PresentationMailbox with EquatableMixin { static final roleArchive = Role(archiveRole); static final roleRecovered = Role(recoveredRole); static final roleFavorite = Role(favoriteRole); + static final roleActionRequired = Role(actionRequiredRole); final MailboxId id; final MailboxName? name; diff --git a/test/features/mailbox/presentation/extensions/list_mailbox_node_extension_test.dart b/test/features/mailbox/presentation/extensions/list_mailbox_node_extension_test.dart index 6f0737fae..cf1a88b90 100644 --- a/test/features/mailbox/presentation/extensions/list_mailbox_node_extension_test.dart +++ b/test/features/mailbox/presentation/extensions/list_mailbox_node_extension_test.dart @@ -94,4 +94,196 @@ void main() { expect(ids, ['1']); }); }); + + group('ListMailboxNodeExtension::insertAfterStarredOrInbox', () { + late List nodes; + + MailboxNode buildNode(String id, {String? name, String? role}) { + final mailbox = PresentationMailbox( + MailboxId(Id(id)), + name: name != null ? MailboxName(name) : null, + role: role != null ? Role(role) : null, + ); + return MailboxNode(mailbox); + } + + test('should insert after Starred when Starred exists (by role)', () { + nodes = [ + buildNode('1', name: 'Inbox', role: 'inbox'), + buildNode('2', name: 'Starred', role: 'favorite'), + buildNode('3', name: 'Trash'), + ]; + + final newNode = buildNode('4', name: 'Draft'); + nodes.insertAfterStarredOrInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2', '4', '3']); + }); + + test( + 'should insert after Starred when role missing but name matches (case-insensitive)', + () { + nodes = [ + buildNode('1', name: 'Inbox'), + buildNode('2', name: 'STARRED'), // no role + buildNode('3', name: 'Trash'), + ]; + + final newNode = buildNode('4', name: 'Draft'); + nodes.insertAfterStarredOrInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2', '4', '3']); + }); + + test('should insert after Inbox when Starred not found', () { + nodes = [ + buildNode('1', name: 'Sent'), + buildNode('2', name: 'Inbox', role: 'inbox'), + buildNode('3', name: 'Trash'), + ]; + + final newNode = buildNode('4', name: 'Archive'); + nodes.insertAfterStarredOrInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2', '4', '3']); + }); + + test('should insert at beginning when neither Starred nor Inbox found', () { + nodes = [ + buildNode('1', name: 'Sent'), + buildNode('2', name: 'Trash'), + ]; + + final newNode = buildNode('3', name: 'Draft'); + nodes.insertAfterStarredOrInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['3', '1', '2']); + }); + + test('should not insert if node with same id already exists', () { + nodes = [ + buildNode('1', name: 'Inbox', role: 'inbox'), + buildNode('2', name: 'Starred', role: 'favorite'), + ]; + + final newNode = + buildNode('2', name: 'Starred', role: 'favorite'); // duplicate id + nodes.insertAfterStarredOrInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2']); + }); + }); + + group('ListMailboxNodeExtension::insertAfterByPriority', () { + late List nodes; + + MailboxNode buildNode(String id, {String? name, String? role}) { + final mailbox = PresentationMailbox( + MailboxId(Id(id)), + name: name != null ? MailboxName(name) : null, + role: role != null ? Role(role) : null, + ); + return MailboxNode(mailbox); + } + + bool isInbox(MailboxNode node) => + node.item.role?.value == PresentationMailbox.inboxRole || + node.item.name?.name.toLowerCase() == 'inbox'; + + bool isStarred(MailboxNode node) => + node.item.role?.value == PresentationMailbox.favoriteRole || + node.item.name?.name.toLowerCase() == 'starred'; + + bool isSent(MailboxNode node) => + node.item.role?.value == PresentationMailbox.sentRole || + node.item.name?.name.toLowerCase() == 'sent'; + + test('should insert after first matched priority', () { + nodes = [ + buildNode('1', name: 'Inbox', role: 'inbox'), + buildNode('2', name: 'Starred', role: 'favorite'), + buildNode('3', name: 'Sent', role: 'sent'), + ]; + + final newNode = buildNode('4', name: 'Draft'); + + nodes.insertAfterByPriority( + newNode, + [isStarred, isInbox, isSent], + ); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2', '4', '3']); + }); + + test('should fallback to second priority when first not found', () { + nodes = [ + buildNode('1', name: 'Inbox', role: 'inbox'), + buildNode('2', name: 'Trash'), + ]; + + final newNode = buildNode('3', name: 'Draft'); + + nodes.insertAfterByPriority( + newNode, + [isStarred, isInbox], + ); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '3', '2']); + }); + + test('should insert at beginning when no priority matched', () { + nodes = [ + buildNode('1', name: 'Trash'), + buildNode('2', name: 'Archive'), + ]; + + final newNode = buildNode('3', name: 'Draft'); + + nodes.insertAfterByPriority( + newNode, + [isStarred, isInbox, isSent], + ); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['3', '1', '2']); + }); + + test('should not insert when node with same id already exists', () { + nodes = [ + buildNode('1', name: 'Inbox', role: 'inbox'), + buildNode('2', name: 'Sent', role: 'sent'), + ]; + + final newNode = buildNode('2', name: 'Sent', role: 'sent'); + + nodes.insertAfterByPriority( + newNode, + [isInbox, isSent], + ); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2']); + }); + + test('should insert into empty list as first element', () { + nodes = []; + + final newNode = buildNode('1', name: 'Inbox', role: 'inbox'); + + nodes.insertAfterByPriority( + newNode, + [isInbox], + ); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1']); + }); + }); } diff --git a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart index f786db1b7..cb4ffe6a6 100644 --- a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart +++ b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart @@ -57,6 +57,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_in import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree_builder.dart'; import 'package:tmail_ui_user/features/mailbox_creator/domain/usecases/verify_name_interactor.dart'; @@ -169,6 +170,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(fallbackGenerators: fallbackGenerators), MockSpec(), MockSpec(), MockSpec(), @@ -245,6 +247,7 @@ void main() { final appGridDashboardController = MockAppGridDashboardController(); final spamReportController = MockSpamReportController(); final networkConnectionController = MockNetworkConnectionController(); + final actionRequiredFolderController = MockActionRequiredFolderController(); // mock search controller direct dependencies final quickSearchEmailInteractor = MockQuickSearchEmailInteractor(); @@ -327,6 +330,7 @@ void main() { Get.put(appGridDashboardController); Get.put(spamReportController); Get.put(networkConnectionController); + Get.put(actionRequiredFolderController); Get.put(cachingManager); Get.put(languageCacheManager); Get.put(authorizationInterceptors); diff --git a/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart b/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart index e96daf350..261b8eb61 100644 --- a/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart +++ b/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart @@ -54,6 +54,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_in import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree_builder.dart'; import 'package:tmail_ui_user/features/mailbox_creator/domain/usecases/verify_name_interactor.dart'; @@ -170,6 +171,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(fallbackGenerators: fallbackGenerators), MockSpec(), MockSpec(), MockSpec(), @@ -236,6 +238,7 @@ void main() { final appGridDashboardController = MockAppGridDashboardController(); final spamReportController = MockSpamReportController(); final networkConnectionController = MockNetworkConnectionController(); + final actionRequiredFolderController = MockActionRequiredFolderController(); final quickSearchEmailInteractor = MockQuickSearchEmailInteractor(); final saveRecentSearchInteractor = MockSaveRecentSearchInteractor(); @@ -323,6 +326,7 @@ void main() { Get.put(appGridDashboardController); Get.put(spamReportController); Get.put(networkConnectionController); + Get.put(actionRequiredFolderController); Get.put(cachingManager); Get.put(languageCacheManager); Get.put(authorizationInterceptors); diff --git a/test/features/search/verify_before_time_in_search_email_filter_test.dart b/test/features/search/verify_before_time_in_search_email_filter_test.dart index 458cbb533..f434aa5b9 100644 --- a/test/features/search/verify_before_time_in_search_email_filter_test.dart +++ b/test/features/search/verify_before_time_in_search_email_filter_test.dart @@ -54,6 +54,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/remove_ import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/save_recent_search_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/store_email_sort_order_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/download_ui_action.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/action_required_folder_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/app_grid_dashboard_controller.dart'; import 'package:tmail_ui_user/features/download/presentation/controllers/download_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; @@ -140,6 +141,7 @@ const fallbackGenerators = { MockSpec(fallbackGenerators: fallbackGenerators), MockSpec(fallbackGenerators: fallbackGenerators), MockSpec(fallbackGenerators: fallbackGenerators), + MockSpec(fallbackGenerators: fallbackGenerators), MockSpec(), MockSpec(), MockSpec(), @@ -207,6 +209,7 @@ void main() { final MockAppGridDashboardController appGridDashboardController = MockAppGridDashboardController(); final MockSpamReportController spamReportController = MockSpamReportController(); final MockNetworkConnectionController networkConnectionController = MockNetworkConnectionController(); + final MockActionRequiredFolderController actionRequiredFolderController = MockActionRequiredFolderController(); final composerManager = MockComposerManager(); late MailboxDashBoardController mailboxDashboardController; @@ -353,6 +356,7 @@ void main() { Get.put(appGridDashboardController); Get.put(spamReportController); Get.put(networkConnectionController); + Get.put(actionRequiredFolderController); Get.put(getSessionInteractor); Get.put(getAuthenticatedAccountInteractor); Get.put(updateAccountCacheInteractor);