TF-1715 Add favorite folder to mailbox left menu

This commit is contained in:
dab246
2025-10-30 10:20:33 +07:00
committed by Dat H. Pham
parent fbb79e17a3
commit d85e691d98
11 changed files with 211 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10 13.6988L14.0643 16.1812C14.0974 16.2014 14.137 16.2077 14.1746 16.1987C14.2533 16.1799 14.3019 16.1009 14.2831 16.0223L13.1781 11.3898L16.795 8.29152C16.8244 8.26635 16.8426 8.23058 16.8457 8.19201C16.8521 8.1114 16.792 8.04081 16.7114 8.03435L11.9642 7.65377L10.1352 3.25648C10.1204 3.22076 10.092 3.19237 10.0563 3.17752C9.9816 3.14646 9.8959 3.18181 9.86484 3.25648L8.03591 7.65377L3.28867 8.03435C3.25011 8.03744 3.21433 8.05567 3.18916 8.08505C3.13655 8.14647 3.14369 8.23891 3.20511 8.29152L6.822 11.3898L5.71698 16.0223C5.70801 16.0599 5.71429 16.0996 5.73445 16.1326C5.77661 16.2016 5.86673 16.2234 5.93574 16.1812L10 13.6988ZM10 15.3727L6.68039 17.4004C5.93806 17.8538 4.96872 17.6196 4.51531 16.8772C4.2984 16.5221 4.23085 16.0956 4.3274 15.6908L5.22996 11.9071L2.27574 9.37646C1.61513 8.81057 1.53834 7.8163 2.10422 7.15568C2.37494 6.83966 2.75971 6.6436 3.17451 6.61035L7.05197 6.29949L8.54581 2.70787C8.87986 1.90472 9.80174 1.52444 10.6049 1.85849C10.9891 2.01829 11.2945 2.32365 11.4543 2.70787L12.9481 6.29949L16.8256 6.61035C17.6926 6.67986 18.3392 7.43911 18.2697 8.30617C18.2364 8.72097 18.0404 9.10575 17.7243 9.37646L14.7701 11.9071L15.6727 15.6908C15.8745 16.5369 15.3522 17.3864 14.5061 17.5883C14.1013 17.6848 13.6748 17.6173 13.3197 17.4004L10 15.3727Z"
fill="#007AFF" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -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');
@@ -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<MailboxNode> 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);
}
}
@@ -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<MailboxNode> {
List<MailboxId> get mailboxIds => map((node) => node.item.id).toList();
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);
}
}
}
@@ -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:
@@ -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();
@@ -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<String>(
const Duration(milliseconds: 300),
@@ -450,7 +465,6 @@ class SearchMailboxController extends BaseMailboxController with MailboxActionHa
}
}
void _handleSubAddressingAction(
MailboxId mailboxId,
Map<String, List<String>?>? currentRights,
+6
View File
@@ -5063,5 +5063,11 @@
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"favoriteMailboxDisplayName": "Starred",
"@favoriteMailboxDisplayName": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
}
}
@@ -5359,4 +5359,11 @@ class AppLocalizations {
name: 'moveFolderContentToastMessage',
);
}
String get favoriteMailboxDisplayName {
return Intl.message(
'Starred',
name: 'favoriteMailboxDisplayName',
);
}
}
@@ -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);
@@ -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<MailboxNode> 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']);
});
});
}