Skip to content

Commit 95f7e53

Browse files
authored
Merge pull request #27 from Aster-Privacy/feat/folder-nesting-reorder
feat(folders): nested subfolders and reordering
2 parents ab34d94 + 9dbb7d9 commit 95f7e53

25 files changed

Lines changed: 1101 additions & 70 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//
2+
// Aster Communications Inc.
3+
//
4+
// Copyright (c) 2026 Aster Communications Inc.
5+
//
6+
// This file is part of this project.
7+
//
8+
// This program is free software: you can redistribute it and/or modify
9+
// it under the terms of the GNU Affero General Public License as published by
10+
// the Free Software Foundation, either version 3 of the License, or
11+
// (at your option) any later version.
12+
//
13+
// This program is distributed in the hope that it will be useful,
14+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
// GNU Affero General Public License for more details.
17+
//
18+
// You should have received a copy of the GNU Affero General Public License
19+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
20+
//
21+
22+
package org.astermail.android.ui.drawer
23+
24+
import androidx.compose.material.icons.Icons
25+
import androidx.compose.material.icons.outlined.Folder
26+
import androidx.compose.ui.test.assert
27+
import androidx.compose.ui.test.assertIsDisplayed
28+
import androidx.compose.ui.test.filterToOne
29+
import androidx.compose.ui.test.hasAnyAncestor
30+
import androidx.compose.ui.test.hasSetTextAction
31+
import androidx.compose.ui.test.hasText
32+
import androidx.compose.ui.test.isPopup
33+
import androidx.compose.ui.test.junit4.createComposeRule
34+
import androidx.compose.ui.test.onAllNodesWithText
35+
import androidx.compose.ui.test.onNodeWithTag
36+
import androidx.compose.ui.test.onNodeWithText
37+
import androidx.compose.ui.test.performClick
38+
import androidx.compose.ui.test.performScrollTo
39+
import androidx.compose.ui.test.performTextInput
40+
import androidx.test.ext.junit.runners.AndroidJUnit4
41+
import org.astermail.android.design.AsterTheme
42+
import org.junit.Rule
43+
import org.junit.Test
44+
import org.junit.runner.RunWith
45+
46+
@RunWith(AndroidJUnit4::class)
47+
class FolderNestingDrawerTest {
48+
49+
@get:Rule
50+
val compose_rule = createComposeRule()
51+
52+
private fun sample_folders() = listOf(
53+
drawer_folder_item(id = "t1", label = "Test 1", icon = Icons.Outlined.Folder, count = 0, depth = 0),
54+
drawer_folder_item(id = "apple", label = "Apple", icon = Icons.Outlined.Folder, count = 0, depth = 1),
55+
drawer_folder_item(id = "boy", label = "Boy", icon = Icons.Outlined.Folder, count = 0, depth = 1),
56+
drawer_folder_item(id = "cat", label = "Cat", icon = Icons.Outlined.Folder, count = 2, depth = 2),
57+
)
58+
59+
private fun sample_parent_options() = listOf(
60+
folder_parent_option(token = "t1", label = "Test 1", depth = 0, path_label = "Test 1"),
61+
folder_parent_option(token = "boy", label = "Boy", depth = 1, path_label = "Test 1 · Boy"),
62+
)
63+
64+
private fun set_drawer_content(
65+
on_create_folder: (String, String?) -> Unit = { _, _ -> },
66+
) {
67+
compose_rule.setContent {
68+
AsterTheme {
69+
DrawerContent(
70+
selected_id = "inbox",
71+
on_select = {},
72+
on_close = {},
73+
api_folder_items = sample_folders(),
74+
folder_parent_options = sample_parent_options(),
75+
on_create_folder = on_create_folder,
76+
)
77+
}
78+
}
79+
}
80+
81+
@Test
82+
fun renders_nested_folder_rows() {
83+
set_drawer_content()
84+
compose_rule.onNodeWithText("Test 1").performScrollTo().assertIsDisplayed()
85+
compose_rule.onNodeWithText("Apple").performScrollTo().assertIsDisplayed()
86+
compose_rule.onNodeWithText("Boy").performScrollTo().assertIsDisplayed()
87+
compose_rule.onNodeWithText("Cat").performScrollTo().assertIsDisplayed()
88+
}
89+
90+
@Test
91+
fun create_folder_dialog_offers_parent_selection() {
92+
var created_name: String? = null
93+
var created_parent: String? = null
94+
set_drawer_content { name, parent ->
95+
created_name = name
96+
created_parent = parent
97+
}
98+
99+
compose_rule.onNodeWithTag("create_folder").performScrollTo().performClick()
100+
compose_rule.onNodeWithText("Parent folder").assertIsDisplayed()
101+
compose_rule.onNodeWithTag("parent_folder_selector").assertIsDisplayed()
102+
103+
compose_rule.onNodeWithTag("parent_folder_selector").performClick()
104+
compose_rule.onAllNodesWithText("None")
105+
.filterToOne(hasAnyAncestor(isPopup()))
106+
.assertIsDisplayed()
107+
compose_rule.onAllNodesWithText("Boy")
108+
.filterToOne(hasAnyAncestor(isPopup()))
109+
.performClick()
110+
111+
compose_rule.onNodeWithText("Test 1 · Boy").assertIsDisplayed()
112+
113+
compose_rule.onNode(hasSetTextAction()).performTextInput("Receipts")
114+
compose_rule.onNodeWithText("Save").performClick()
115+
116+
compose_rule.runOnIdle {
117+
assert(created_name == "Receipts")
118+
assert(created_parent == "boy")
119+
}
120+
}
121+
122+
@Test
123+
fun create_folder_defaults_to_no_parent() {
124+
var created_parent: String? = "sentinel"
125+
set_drawer_content { _, parent ->
126+
created_parent = parent
127+
}
128+
129+
compose_rule.onNodeWithTag("create_folder").performScrollTo().performClick()
130+
compose_rule.onNode(hasSetTextAction()).performTextInput("Top Level")
131+
compose_rule.onNodeWithText("Save").performClick()
132+
133+
compose_rule.runOnIdle {
134+
assert(created_parent == null)
135+
}
136+
}
137+
138+
@Test
139+
fun dropdown_none_option_clears_selection() {
140+
set_drawer_content()
141+
142+
compose_rule.onNodeWithTag("create_folder").performScrollTo().performClick()
143+
compose_rule.onNodeWithTag("parent_folder_selector").performClick()
144+
compose_rule.onAllNodesWithText("Test 1")
145+
.filterToOne(hasAnyAncestor(isPopup()))
146+
.performClick()
147+
compose_rule.onNodeWithTag("parent_folder_selector").performClick()
148+
compose_rule.onAllNodesWithText("None")
149+
.filterToOne(hasAnyAncestor(isPopup()))
150+
.performClick()
151+
compose_rule.onNodeWithTag("parent_folder_selector").assert(hasText("None"))
152+
}
153+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//
2+
// Aster Communications Inc.
3+
//
4+
// Copyright (c) 2026 Aster Communications Inc.
5+
//
6+
// This file is part of this project.
7+
//
8+
// This program is free software: you can redistribute it and/or modify
9+
// it under the terms of the GNU Affero General Public License as published by
10+
// the Free Software Foundation, either version 3 of the License, or
11+
// (at your option) any later version.
12+
//
13+
// This program is distributed in the hope that it will be useful,
14+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
// GNU Affero General Public License for more details.
17+
//
18+
// You should have received a copy of the GNU Affero General Public License
19+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
20+
//
21+
22+
package org.astermail.android.ui.drawer
23+
24+
import android.graphics.Bitmap
25+
import androidx.compose.material.icons.Icons
26+
import androidx.compose.material.icons.outlined.Folder
27+
import androidx.compose.ui.graphics.asAndroidBitmap
28+
import androidx.compose.ui.test.SemanticsNodeInteraction
29+
import androidx.compose.ui.test.captureToImage
30+
import androidx.compose.ui.test.filterToOne
31+
import androidx.compose.ui.test.hasAnyAncestor
32+
import androidx.compose.ui.test.hasSetTextAction
33+
import androidx.compose.ui.test.isDialog
34+
import androidx.compose.ui.test.isPopup
35+
import androidx.compose.ui.test.junit4.createComposeRule
36+
import androidx.compose.ui.test.onAllNodesWithText
37+
import androidx.compose.ui.test.onNodeWithTag
38+
import androidx.compose.ui.test.onNodeWithText
39+
import androidx.compose.ui.test.onRoot
40+
import androidx.compose.ui.test.performClick
41+
import androidx.compose.ui.test.performScrollTo
42+
import androidx.compose.ui.test.performTextInput
43+
import androidx.test.ext.junit.runners.AndroidJUnit4
44+
import androidx.test.platform.app.InstrumentationRegistry
45+
import org.astermail.android.design.AsterTheme
46+
import org.junit.Rule
47+
import org.junit.Test
48+
import org.junit.runner.RunWith
49+
import java.io.File
50+
import java.io.FileOutputStream
51+
52+
@RunWith(AndroidJUnit4::class)
53+
class FolderNestingScreenshotTest {
54+
55+
@get:Rule
56+
val compose_rule = createComposeRule()
57+
58+
private fun save_screenshot(name: String, node: SemanticsNodeInteraction = compose_rule.onRoot()) {
59+
val bitmap = node.captureToImage().asAndroidBitmap()
60+
val dir = InstrumentationRegistry.getInstrumentation()
61+
.targetContext.getExternalFilesDir(null) ?: return
62+
FileOutputStream(File(dir, "$name.png")).use { out ->
63+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
64+
}
65+
}
66+
67+
@Test
68+
fun capture_nested_drawer_and_create_dialog() {
69+
compose_rule.setContent {
70+
AsterTheme {
71+
DrawerContent(
72+
selected_id = "inbox",
73+
on_select = {},
74+
on_close = {},
75+
api_folder_items = listOf(
76+
drawer_folder_item(id = "t1", label = "Test 1", icon = Icons.Outlined.Folder, count = 3, depth = 0),
77+
drawer_folder_item(id = "apple", label = "Apple", icon = Icons.Outlined.Folder, count = 0, depth = 1, trail = listOf(true), has_next = true),
78+
drawer_folder_item(id = "boy", label = "Boy", icon = Icons.Outlined.Folder, count = 1, depth = 1, trail = listOf(true), has_next = false),
79+
drawer_folder_item(id = "cat", label = "Cat", icon = Icons.Outlined.Folder, count = 0, depth = 2, trail = listOf(true, false), has_next = false),
80+
drawer_folder_item(id = "zoo", label = "Zoo", icon = Icons.Outlined.Folder, count = 0, depth = 0),
81+
),
82+
folder_parent_options = listOf(
83+
folder_parent_option(token = "t1", label = "Test 1", depth = 0, path_label = "Test 1"),
84+
folder_parent_option(token = "boy", label = "Boy", depth = 1, path_label = "Test 1 · Boy"),
85+
folder_parent_option(token = "zoo", label = "Zoo", depth = 0, path_label = "Zoo"),
86+
),
87+
)
88+
}
89+
}
90+
91+
compose_rule.onNodeWithText("Zoo").performScrollTo()
92+
compose_rule.waitForIdle()
93+
save_screenshot("folder_drawer_nested")
94+
95+
compose_rule.onNodeWithTag("create_folder").performScrollTo().performClick()
96+
compose_rule.waitForIdle()
97+
compose_rule.onNode(hasSetTextAction()).performTextInput("Receipts")
98+
compose_rule.onNodeWithTag("parent_folder_selector").performClick()
99+
compose_rule.waitForIdle()
100+
compose_rule.onAllNodesWithText("Boy")
101+
.filterToOne(hasAnyAncestor(isPopup()))
102+
.performClick()
103+
compose_rule.waitForIdle()
104+
save_screenshot("folder_create_dialog", compose_rule.onNode(isDialog()))
105+
}
106+
}

app/src/main/kotlin/org/astermail/android/MainActivity.kt

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,29 +1186,44 @@ private fun InboxWithDrawer(nav_controller: NavHostController) {
11861186
?: accounts_state.accounts.firstOrNull()?.email
11871187
?: ""
11881188

1189-
val api_folders = settings_state.labels
1190-
.filter { !it.is_system }
1191-
.filter { it.folder_type == "folder" || it.folder_type == "custom" }
1192-
.map { label ->
1189+
val folder_nodes = org.astermail.android.folders.flatten_folder_tree(settings_state.labels)
1190+
1191+
val api_folders = folder_nodes.map { node ->
1192+
val label = node.label
1193+
val readable_name = label.encrypted_name?.takeIf { it.isNotBlank() && !looks_encrypted(it) }
1194+
drawer_folder_item(
1195+
id = label.label_token,
1196+
label = readable_name ?: drawer_context.getString(R.string.folder_decrypt_failed),
1197+
icon = Icons.Outlined.Folder,
1198+
count = label.unread_count?.toInt() ?: 0,
1199+
depth = node.depth,
1200+
trail = node.trail,
1201+
has_next = node.has_next,
1202+
)
1203+
}
1204+
1205+
val folder_parent_options = folder_nodes
1206+
.filter { it.depth < org.astermail.android.folders.max_folder_depth }
1207+
.mapNotNull { node ->
1208+
val label = node.label
11931209
val readable_name = label.encrypted_name?.takeIf { it.isNotBlank() && !looks_encrypted(it) }
1194-
drawer_folder_item(
1195-
id = label.label_token,
1196-
label = readable_name ?: drawer_context.getString(R.string.folder_decrypt_failed),
1197-
icon = Icons.Outlined.Folder,
1198-
count = label.unread_count?.toInt() ?: 0,
1210+
?: return@mapNotNull null
1211+
org.astermail.android.ui.drawer.folder_parent_option(
1212+
token = label.label_token,
1213+
label = readable_name,
1214+
depth = node.depth,
1215+
path_label = org.astermail.android.folders.folder_path(settings_state.labels, label.label_token)
1216+
.filter { it.isNotBlank() && !looks_encrypted(it) }
1217+
.joinToString(" · "),
11991218
)
12001219
}
12011220

1202-
val quick_custom_folders = androidx.compose.runtime.remember(settings_state.labels) {
1203-
settings_state.labels
1204-
.filter { !it.is_system }
1205-
.filter { it.folder_type == "folder" || it.folder_type == "custom" }
1206-
.mapNotNull { label ->
1207-
val readable_name = label.encrypted_name?.takeIf { it.isNotBlank() && !looks_encrypted(it) }
1208-
?: return@mapNotNull null
1209-
label.label_token to readable_name
1210-
}
1211-
}
1221+
val quick_custom_folders = folder_nodes
1222+
.mapNotNull { node ->
1223+
val readable_name = node.label.encrypted_name?.takeIf { it.isNotBlank() && !looks_encrypted(it) }
1224+
?: return@mapNotNull null
1225+
node.label.label_token to readable_name
1226+
}
12121227

12131228
val label_colors = listOf(
12141229
Color(0xFF3B82F6),
@@ -1378,9 +1393,18 @@ private fun InboxWithDrawer(nav_controller: NavHostController) {
13781393
on_create_label = { name, color, icon ->
13791394
settings_vm.create_tag(name = name, color = color, icon = icon)
13801395
},
1381-
on_create_folder = { name ->
1382-
settings_vm.create_folder(name = name, sort_order = api_folders.size)
1396+
on_create_folder = { name, parent_token ->
1397+
val sibling_count = settings_state.labels.count {
1398+
org.astermail.android.folders.is_custom_folder(it) &&
1399+
it.parent_token.orEmpty() == parent_token.orEmpty()
1400+
}
1401+
settings_vm.create_folder(
1402+
name = name,
1403+
sort_order = sibling_count,
1404+
parent_token = parent_token,
1405+
)
13831406
},
1407+
folder_parent_options = folder_parent_options,
13841408
on_logout = {
13851409
settings_vm.logout {
13861410
accounts_vm.refresh()

0 commit comments

Comments
 (0)