feat(drawer): show per-folder unread counts and bold accounts with unread mail

Adds a live unread-count signal shared by two navigation-drawer indicators:

- #83: each folder row shows a trailing unread-count badge (capped at
  "99+"), hidden when zero. Screen readers announce the exact count via a
  plurals content description.
- #84: accounts with unread mail render their email in bold in the drawer
  account switcher and the mailbox account-filter chips.

Both derive from one efficient Room aggregate, MessageDao.observeUnreadCounts():
a COUNT(*) ... GROUP BY accountId, folder over folder-synced rows
(inInbox = 1 AND isRead = 0) that pulls no message rows into memory. Its
GROUP BY is served by the existing (accountId, folder, uid) index, so no
schema change or migration is needed. MailboxViewModel derives
folderUnreadCounts (drawer account, per folder) and accountsWithUnread
(any folder) from the one shared flow.

Unread scope is folder-synced mail in any folder, kept consistent across
both features: an account reads as bold exactly when one of its folders
shows a badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 02:37:24 -05:00
co-authored by Claude Fable 5
parent 29e26bc1a6
commit 9b970af2d1
14 changed files with 264 additions and 7 deletions
@@ -53,6 +53,34 @@ class LibreMailDatabaseTest {
isStarred = false,
)
@Test
fun observeUnreadCountsAggregatesUnreadSyncedRowsPerAccountAndFolder() = runBlocking {
val messageDao = db.messageDao()
messageDao.insertNew(
listOf(
// acct / INBOX: two unread + one read -> counts 2.
message("acct:INBOX:1").copy(folder = "INBOX", isRead = false),
message("acct:INBOX:2").copy(folder = "INBOX", isRead = false),
message("acct:INBOX:3").copy(folder = "INBOX", isRead = true),
// acct / Archive: one unread -> counts 1.
message("acct:Archive:1").copy(folder = "Archive", isRead = false),
// An unread server-search hit (inInbox = false) must never inflate a badge.
message("acct:INBOX:search").copy(folder = "INBOX", isRead = false, inInbox = false),
// A second account's unread inbox row is counted under its own accountId.
message("acct2:INBOX:1").copy(accountId = "acct2", folder = "INBOX", isRead = false),
),
)
val counts = messageDao.observeUnreadCounts().first()
.associate { (it.accountId to it.folder) to it.unreadCount }
assertEquals(2, counts[("acct" to "INBOX")])
assertEquals(1, counts[("acct" to "Archive")])
assertEquals(1, counts[("acct2" to "INBOX")])
// Fully-read folders and search-only rows produce no group at all.
assertEquals(3, counts.size)
}
@Test
fun deletingMessageCascadesToItsAttachments() = runBlocking {
val messageDao = db.messageDao()
@@ -14,6 +14,7 @@ import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.UnreadCount
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import java.io.File
@@ -72,6 +73,7 @@ class FakeMailRepository(
private val folders: List<Folder> = emptyList(),
private val attachments: List<Attachment> = emptyList(),
private val downloadedParts: Set<Int> = emptySet(),
private val unreadCounts: List<UnreadCount> = emptyList(),
) : MailRepository {
val sentMessages = mutableListOf<OutgoingMessage>()
@@ -93,6 +95,8 @@ class FakeMailRepository(
},
)
override fun observeUnreadCounts(): Flow<List<UnreadCount>> = flowOf(unreadCounts)
override suspend fun refreshFolders(accountId: String): Result<Unit> = Result.success(Unit)
override suspend fun getMessage(id: String): Message? = messages.firstOrNull { it.id == id }
@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -145,10 +146,34 @@ class FolderDrawerTest {
assertTrue(unifiedTapped)
}
@Test
fun folderWithUnreadMail_showsCountBadge_andReadFolderShowsNone() {
setContent(
accounts = listOf(alice),
drawerAccount = alice,
folders = listOf(
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
),
folderUnreadCounts = mapOf("INBOX" to 3),
)
// The inbox badge announces its exact count for screen readers.
val threeUnread = composeTestRule.activity.resources
.getQuantityString(R.plurals.folder_unread_count_description, 3, 3)
composeTestRule.onNodeWithContentDescription(threeUnread).assertIsDisplayed()
// Archive has no unread mail, so no badge is rendered for it.
val oneUnread = composeTestRule.activity.resources
.getQuantityString(R.plurals.folder_unread_count_description, 1, 1)
composeTestRule.onNodeWithContentDescription(oneUnread).assertDoesNotExist()
}
private fun setContent(
accounts: List<Account>,
drawerAccount: Account?,
folders: List<Folder>,
folderUnreadCounts: Map<String, Int> = emptyMap(),
accountsWithUnread: Set<String> = emptySet(),
selectedAccountId: String? = null,
selectedFolder: String = "INBOX",
onSelectUnifiedInbox: () -> Unit = {},
@@ -162,6 +187,8 @@ class FolderDrawerTest {
accounts = accounts,
drawerAccount = drawerAccount,
folders = folders,
folderUnreadCounts = folderUnreadCounts,
accountsWithUnread = accountsWithUnread,
selectedAccountId = selectedAccountId,
selectedFolder = selectedFolder,
onSelectUnifiedInbox = onSelectUnifiedInbox,