feat(message): move message actions from dropdown to top-bar icons
The multi-select contextual action bar buried Archive, Spam, Move, Select all, and the single-selection Reply/Reply All/Forward behind one MoreVert dropdown; only Close and Delete were direct. Promote the common actions to direct IconButtons, matching the reader app bar's icons-not-menus pattern: Archive (Done glyph - material-icons-core has no archive icon, so this leans on the "done = archive" mail idiom), Spam (Warning), and Delete, each with a contentDescription for accessibility. The overflow keeps only the long tail: Move (no usable core glyph, per the ticket it stays text-labeled), Select all, and the single-selection reply actions. All conditional visibility is preserved: Archive/Spam still hide while viewing their own role folder, Move still requires a single-account selection, and the reply actions still require exactly one selected message. Four 48dp actions plus Close still fit a 320dp-wide bar; the count title just truncates earlier. UI tests: the direct Archive icon archives without opening the overflow, the direct Spam icon still confirms before reporting, the Archive icon hides inside the archive folder, and the overflow test now keys on Select all instead of the promoted Archive. Closes #87 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,8 +54,8 @@ class MailboxScreenTest {
|
||||
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private fun message(uid: String, subject: String, bodyFetched: Boolean = false) = Message(
|
||||
id = "imap:a:INBOX:$uid",
|
||||
private fun message(uid: String, subject: String, bodyFetched: Boolean = false, folder: String = "INBOX") = Message(
|
||||
id = "imap:a:$folder:$uid",
|
||||
accountId = "imap:a",
|
||||
sender = "Sender $uid",
|
||||
senderEmail = "s$uid@example.org",
|
||||
@@ -66,12 +66,12 @@ class MailboxScreenTest {
|
||||
timestampMillis = 1_000L,
|
||||
isRead = true,
|
||||
isStarred = false,
|
||||
folder = "INBOX",
|
||||
folder = folder,
|
||||
inInbox = true,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
private fun setContent(repo: FakeMailRepository) {
|
||||
private fun setContent(repo: FakeMailRepository): MailboxViewModel {
|
||||
val viewModel = MailboxViewModel(
|
||||
repo,
|
||||
FakeAccountRepository(accounts = listOf(account)),
|
||||
@@ -92,6 +92,7 @@ class MailboxScreenTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
return viewModel
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
@@ -132,11 +133,58 @@ class MailboxScreenTest {
|
||||
composeTestRule.onNodeWithText("Second").performClick()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.action_archive)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_select_all)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveIcon_isDirect_andArchivesTheSelection() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second")))
|
||||
setContent(repo)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithText("Second").performClick()
|
||||
// A direct icon button — no trip through the overflow menu.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.archivedIds.isNotEmpty() }
|
||||
assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), repo.archivedIds.first().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spamIcon_isDirect_andConfirmsBeforeReporting() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
|
||||
setContent(repo)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.spammedIds.isNotEmpty() }
|
||||
assertEquals(listOf("imap:a:INBOX:1"), repo.spammedIds.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveIcon_hides_whileViewingTheArchiveFolder() {
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message("1", "Old news", folder = "Archive")),
|
||||
folders = listOf(Folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE, selectable = true)),
|
||||
)
|
||||
val viewModel = setContent(repo)
|
||||
viewModel.selectFolder("imap:a", "Archive")
|
||||
waitForText("Old news")
|
||||
|
||||
composeTestRule.onNodeWithText("Old news").performTouchInput { longClick() }
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delete_confirmsMoveToTrash_thenTrashesViaRepository() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
|
||||
|
||||
@@ -31,11 +31,13 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -530,9 +532,12 @@ private fun SelectedAvatar() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The contextual action bar shown while messages are selected. Archive/Delete are the common actions;
|
||||
* the overflow holds the rest. Reply/Reply All/Forward appear only for a single selected message, and
|
||||
* Archive/Spam are hidden while already viewing that role's folder.
|
||||
* The contextual action bar shown while messages are selected. The common actions — Archive, Spam,
|
||||
* Delete — are direct icon buttons (matching the reader's icons-not-menus app bar); Archive/Spam are
|
||||
* hidden while already viewing that role's folder. The overflow keeps only the long tail: Move (no
|
||||
* usable glyph in material-icons-core) and Select all, plus Reply/Reply All/Forward for a single
|
||||
* selected message. At most four 48dp actions plus the close button fit a 320dp-wide bar; the
|
||||
* count title just truncates earlier on such screens.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -558,6 +563,18 @@ private fun SelectionTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (folderRole != FolderRole.ARCHIVE) {
|
||||
IconButton(onClick = onArchive) {
|
||||
// material-icons-core ships no archive glyph; the checkmark leans on the
|
||||
// "done with it = archive it" mail idiom (Google Inbox's sweep).
|
||||
Icon(Icons.Filled.Done, contentDescription = stringResource(R.string.action_archive))
|
||||
}
|
||||
}
|
||||
if (folderRole != FolderRole.SPAM) {
|
||||
IconButton(onClick = onSpam) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = stringResource(R.string.action_spam))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
@@ -566,24 +583,6 @@ private fun SelectionTopBar(
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more))
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (folderRole != FolderRole.ARCHIVE) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_archive)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onArchive()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (folderRole != FolderRole.SPAM) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_spam)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSpam()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canMove) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_move)) },
|
||||
|
||||
Reference in New Issue
Block a user