diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt index 0be3688..fafb851 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt @@ -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"))) diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 5c87165..aaebd11 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -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)) },