From d85e691d9872825a693cbf75ee0e92d2d913a3f4 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 30 Oct 2025 10:20:33 +0700 Subject: [PATCH] TF-1715 Add favorite folder to mailbox left menu --- assets/images/ic_mailbox_favorite.svg | 5 + .../presentation/resources/image_paths.dart | 1 + .../handle_favorite_tab_extension.dart | 48 +++++++++ .../list_mailbox_node_extension.dart | 21 +++- .../presentation_mailbox_extension.dart | 4 + .../presentation/mailbox_controller.dart | 3 + .../search_mailbox_controller.dart | 16 ++- lib/l10n/intl_messages.arb | 6 ++ lib/main/localizations/app_localizations.dart | 7 ++ model/lib/mailbox/presentation_mailbox.dart | 6 ++ .../list_mailbox_node_extension_test.dart | 97 +++++++++++++++++++ 11 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 assets/images/ic_mailbox_favorite.svg create mode 100644 lib/features/mailbox/presentation/extensions/handle_favorite_tab_extension.dart create mode 100644 test/features/mailbox/presentation/extensions/list_mailbox_node_extension_test.dart diff --git a/assets/images/ic_mailbox_favorite.svg b/assets/images/ic_mailbox_favorite.svg new file mode 100644 index 000000000..5cca3f986 --- /dev/null +++ b/assets/images/ic_mailbox_favorite.svg @@ -0,0 +1,5 @@ + + + diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index 0cfd8d637..f62ec5738 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -44,6 +44,7 @@ class ImagePaths { String get icMailboxArchived => _getImagePath('ic_mailbox_archived.svg'); 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 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_favorite_tab_extension.dart b/lib/features/mailbox/presentation/extensions/handle_favorite_tab_extension.dart new file mode 100644 index 000000000..373b96311 --- /dev/null +++ b/lib/features/mailbox/presentation/extensions/handle_favorite_tab_extension.dart @@ -0,0 +1,48 @@ +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/main/routes/route_navigation.dart'; + +extension HandleFavoriteTabExtension on BaseMailboxController { + void addFavoriteFolderToMailboxList() { + PresentationMailbox favoriteFolder = PresentationMailbox.favoriteFolder; + if (currentContext != null) { + favoriteFolder = favoriteFolder.copyWith( + displayName: favoriteFolder.getDisplayName(currentContext!), + ); + } + + _addFavoriteFolderToDefaultMailboxTree(favoriteFolder); + _addFavoriteFolderToAllMailboxes(favoriteFolder); + } + + void _addFavoriteFolderToDefaultMailboxTree( + PresentationMailbox favoriteFolder, + ) { + final defaultMailboxNode = defaultMailboxTree.value.root; + List currentDefaultFolders = + defaultMailboxNode.childrenItems ?? []; + + if (currentDefaultFolders.isEmpty) { + currentDefaultFolders.add(MailboxNode(favoriteFolder)); + } else { + currentDefaultFolders.insertAfterInbox(MailboxNode(favoriteFolder)); + } + + defaultMailboxTree.value = MailboxTree( + defaultMailboxNode.copyWith(children: currentDefaultFolders), + ); + } + + void _addFavoriteFolderToAllMailboxes(PresentationMailbox favoriteFolder) { + final alreadyExists = allMailboxes.any( + (mailbox) => mailbox.id == favoriteFolder.id, + ); + if (alreadyExists) return; + + allMailboxes.add(favoriteFolder); + } +} 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 8338a14ed..8e34e9096 100644 --- a/lib/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart +++ b/lib/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart @@ -1,7 +1,24 @@ - import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_node.dart'; extension ListMailboxNodeExtension on List { List get mailboxIds => map((node) => node.item.id).toList(); -} \ No newline at end of file + + 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', + ); + + if (index != -1) { + insert(index + 1, newNode); + } else { + insert(0, newNode); + } + } +} diff --git a/lib/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart b/lib/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart index cd7260b5a..c91f17709 100644 --- a/lib/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart +++ b/lib/features/mailbox/presentation/extensions/presentation_mailbox_extension.dart @@ -15,6 +15,8 @@ extension PresentationMailboxExtension on PresentationMailbox { switch(role!.value.toLowerCase()) { case PresentationMailbox.inboxRole: return AppLocalizations.of(context).inboxMailboxDisplayName; + case PresentationMailbox.favoriteRole: + return AppLocalizations.of(context).favoriteMailboxDisplayName; case PresentationMailbox.archiveRole: return AppLocalizations.of(context).archiveMailboxDisplayName; case PresentationMailbox.draftsRole: @@ -69,6 +71,8 @@ extension PresentationMailboxExtension on PresentationMailbox { switch(role!.value) { case PresentationMailbox.inboxRole: return imagePaths.icMailboxInbox; + case PresentationMailbox.favoriteRole: + return imagePaths.icMailboxFavorite; 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 363499f67..568b0bb9b 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_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'; import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_actions.dart'; @@ -256,12 +257,14 @@ class MailboxController extends BaseMailboxController viewState.value.fold( (failure) { if (failure is GetAllMailboxFailure) { + addFavoriteFolderToMailboxList(); mailboxDashBoardController.updateRefreshAllMailboxState(Left(RefreshAllMailboxFailure())); showRetryToast(failure); } }, (success) { if (success is GetAllMailboxSuccess) { + addFavoriteFolderToMailboxList(); mailboxDashBoardController.updateRefreshAllMailboxState(Right(RefreshAllMailboxSuccess())); _handleCreateDefaultFolderIfMissing(mailboxDashBoardController.mapDefaultMailboxIdByRole); _handleDataFromNavigationRouter(); diff --git a/lib/features/search/mailbox/presentation/search_mailbox_controller.dart b/lib/features/search/mailbox/presentation/search_mailbox_controller.dart index 271192063..d6f0a8280 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_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'; import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree_builder.dart'; @@ -207,6 +208,20 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa } } + @override + void onDone() { + super.onDone(); + viewState.value.fold((failure) { + if (failure is GetAllMailboxFailure) { + addFavoriteFolderToMailboxList(); + } + }, (success) { + if (success is GetAllMailboxSuccess) { + addFavoriteFolderToMailboxList(); + } + }); + } + void _initializeDebounceTimeTextSearchChange() { _deBouncerTime = Debouncer( const Duration(milliseconds: 300), @@ -450,7 +465,6 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa } } - void _handleSubAddressingAction( MailboxId mailboxId, Map?>? currentRights, diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 9dd41e721..b05ab4f60 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -5063,5 +5063,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "favoriteMailboxDisplayName": "Starred", + "@favoriteMailboxDisplayName": { + "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 89158e39f..abe6ba09e 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -5359,4 +5359,11 @@ class AppLocalizations { name: 'moveFolderContentToastMessage', ); } + + String get favoriteMailboxDisplayName { + return Intl.message( + 'Starred', + name: 'favoriteMailboxDisplayName', + ); + } } diff --git a/model/lib/mailbox/presentation_mailbox.dart b/model/lib/mailbox/presentation_mailbox.dart index 00d225b48..0d486949b 100644 --- a/model/lib/mailbox/presentation_mailbox.dart +++ b/model/lib/mailbox/presentation_mailbox.dart @@ -18,8 +18,14 @@ class PresentationMailbox with EquatableMixin { static const String spamRole = 'spam'; static const String archiveRole = 'archive'; static const String recoveredRole = 'restored messages'; + static const String favoriteRole = 'favorite'; static final PresentationMailbox unifiedMailbox = PresentationMailbox(MailboxId(Id('unified'))); + static final PresentationMailbox favoriteFolder = PresentationMailbox( + MailboxId(Id(favoriteRole)), + name: MailboxName('Starred'), + role: Role(favoriteRole), + ); static final roleInbox = Role(inboxRole); static final roleTrash = Role(trashRole); 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 new file mode 100644 index 000000000..6f0737fae --- /dev/null +++ b/test/features/mailbox/presentation/extensions/list_mailbox_node_extension_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/extensions/list_mailbox_node_extension.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_node.dart'; + +void main() { + group('ListMailboxNodeExtension::insertAfterInbox', () { + 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); + } + + setUp(() { + nodes = [ + buildNode('1', name: 'Sent'), + buildNode('2', name: 'Inbox', role: 'inbox'), + buildNode('3', name: 'Trash'), + ]; + }); + + test('should insert new node right after Inbox (by role)', () { + final newNode = buildNode('4', name: 'Draft'); + nodes.insertAfterInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2', '4', '3']); + }); + + test('should insert new node right after Inbox (by name when role missing)', + () { + nodes = [ + buildNode('1', name: 'Sent'), + buildNode('2', name: 'inbox'), // no role + buildNode('3', name: 'Trash'), + ]; + + final newNode = buildNode('4', name: 'Archive'); + nodes.insertAfterInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2', '4', '3']); + }); + + test('should insert new node at the beginning when no Inbox found', () { + nodes = [ + buildNode('1', name: 'Sent'), + buildNode('2', name: 'Archive'), + ]; + + final newNode = buildNode('3', name: 'Draft'); + nodes.insertAfterInbox(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', () { + final newNode = + buildNode('2', name: 'Inbox', role: 'inbox'); // same id as existing + nodes.insertAfterInbox(newNode); + + // The list should remain unchanged + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2', '3']); + }); + + test('should handle case-insensitive inbox name', () { + nodes = [ + buildNode('1', name: 'Sent'), + buildNode('2', name: 'INBOX'), + ]; + + final newNode = buildNode('3', name: 'Draft'); + nodes.insertAfterInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1', '2', '3']); + }); + + test('should insert into empty list as the first element', () { + nodes = []; + final newNode = buildNode('1', name: 'Inbox', role: 'inbox'); + nodes.insertAfterInbox(newNode); + + final ids = nodes.mailboxIds.map((id) => id.id.value).toList(); + expect(ids, ['1']); + }); + }); +}