diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt index 16cbccf..b4ed6cb 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -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() diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt index c4dade3..b3adafa 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt @@ -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 = emptyList(), private val attachments: List = emptyList(), private val downloadedParts: Set = emptySet(), + private val unreadCounts: List = emptyList(), ) : MailRepository { val sentMessages = mutableListOf() @@ -93,6 +95,8 @@ class FakeMailRepository( }, ) + override fun observeUnreadCounts(): Flow> = flowOf(unreadCounts) + override suspend fun refreshFolders(accountId: String): Result = Result.success(Unit) override suspend fun getMessage(id: String): Message? = messages.firstOrNull { it.id == id } diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt index 711d87c..2abb830 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt @@ -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, drawerAccount: Account?, folders: List, + folderUnreadCounts: Map = emptyMap(), + accountsWithUnread: Set = 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, diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index 3247fbe..c05bbb9 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -8,6 +8,7 @@ import org.libremail.data.local.entity.AccountSettingsEntity import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.FolderEntity +import org.libremail.data.local.entity.FolderUnreadCount import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.MessageSummary import org.libremail.data.local.entity.OutboxEntity @@ -26,6 +27,7 @@ import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.ServerConfig import org.libremail.domain.model.SmtpParams +import org.libremail.domain.model.UnreadCount import org.libremail.mail.AttachmentPart import org.libremail.mail.FetchedFolder import org.libremail.mail.FetchedMessage @@ -168,6 +170,12 @@ internal fun FetchedFolder.toEntity(accountId: String, sortOrder: Int): FolderEn specialUse = FolderRole.isServerSpecial(attributes), ) +internal fun FolderUnreadCount.toDomain(): UnreadCount = UnreadCount( + accountId = accountId, + folder = folder, + count = unreadCount, +) + internal fun AttachmentEntity.toDomain(): Attachment = Attachment( messageId = messageId, partIndex = partIndex, diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt index dfc556c..b0399cc 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt @@ -6,6 +6,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.FolderUnreadCount import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.MessageSummary @@ -23,6 +24,19 @@ interface MessageDao { ) fun observeSummaries(): Flow> + /** + * Live per-(account, folder) unread counts for the drawer's folder badges and the bold styling of + * accounts with unread mail. Counts only folder-synced rows (`inInbox = 1`), so transient + * server-search hits never inflate a badge; read rows and folders with no unread mail are simply + * absent from the result. A pure `COUNT(*)` aggregate — no message rows are pulled into memory — + * whose `GROUP BY accountId, folder` is served by the existing `(accountId, folder, uid)` index. + */ + @Query( + "SELECT accountId, folder, COUNT(*) AS unreadCount FROM messages " + + "WHERE inInbox = 1 AND isRead = 0 GROUP BY accountId, folder", + ) + fun observeUnreadCounts(): Flow> + @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") suspend fun getById(id: String): MessageEntity? diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/FolderUnreadCount.kt b/app/src/main/kotlin/org/libremail/data/local/entity/FolderUnreadCount.kt new file mode 100644 index 0000000..9f53a00 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/FolderUnreadCount.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +/** + * Aggregate projection of [MessageEntity] counting unread, folder-synced rows per (account, folder). + * Produced by `MessageDao.observeUnreadCounts`' `GROUP BY` query — never a stored table — so the + * counts come straight from SQLite without pulling any message rows into memory. + */ +data class FolderUnreadCount(val accountId: String, val folder: String, val unreadCount: Int) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index ba3f629..61ff531 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -36,6 +36,7 @@ import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.model.ReplyMode +import org.libremail.domain.model.UnreadCount import org.libremail.domain.repository.MailRepository import org.libremail.mail.ImapClient import java.io.File @@ -68,6 +69,10 @@ class MailRepositoryImpl @Inject constructor( rows.map { it.toDomain() } } + override fun observeUnreadCounts(): Flow> = messageDao.observeUnreadCounts().map { rows -> + rows.map { it.toDomain() } + } + override suspend fun refreshFolders(accountId: String): Result = runCatching { val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found") val params = connectionFactory.imapParamsFor(account) diff --git a/app/src/main/kotlin/org/libremail/domain/model/UnreadCount.kt b/app/src/main/kotlin/org/libremail/domain/model/UnreadCount.kt new file mode 100644 index 0000000..3e0db33 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/UnreadCount.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** + * The number of unread, folder-synced messages in one account's folder. Emitted per (account, folder) + * pair that currently holds unread mail; pairs with no unread mail are simply absent. Feeds the + * drawer's per-folder unread badges (#83) and the bold styling of accounts that have unread mail (#84). + */ +data class UnreadCount(val accountId: String, val folder: String, val count: Int) diff --git a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt index 1a761b5..4126fc6 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -9,6 +9,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 java.io.File /** @@ -21,6 +22,13 @@ interface MailRepository { /** The account's cached IMAP folders for the navigation drawer. */ fun observeFolders(accountId: String): Flow> + /** + * Live per-(account, folder) unread counts across every account, for the drawer's folder badges + * and the bold styling of accounts with unread mail. Only (account, folder) pairs that currently + * hold unread, folder-synced mail are emitted. + */ + fun observeUnreadCounts(): Flow> + /** Refreshes the account's folder list from the server into the cache. */ suspend fun refreshFolders(accountId: String): Result diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/FolderDrawer.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/FolderDrawer.kt index de9a918..8695334 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/FolderDrawer.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/FolderDrawer.kt @@ -27,7 +27,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.libremail.R @@ -37,13 +41,17 @@ import org.libremail.domain.model.FolderRole /** * The navigation drawer's contents: an optional account switcher and "All Inboxes" entry (only with - * 2+ accounts), then the drawer account's folders — standard folders (Inbox, Sent, …) first. + * 2+ accounts), then the drawer account's folders — standard folders (Inbox, Sent, …) first. Each + * folder shows its unread count as a trailing badge ([folderUnreadCounts]), and accounts with unread + * mail ([accountsWithUnread]) render their email in bold. */ @Composable fun FolderDrawer( accounts: List, drawerAccount: Account?, folders: List, + folderUnreadCounts: Map, + accountsWithUnread: Set, selectedAccountId: String?, selectedFolder: String, onSelectUnifiedInbox: () -> Unit, @@ -63,7 +71,7 @@ fun FolderDrawer( ) if (accounts.size >= 2 && drawerAccount != null) { - AccountSwitcher(accounts, drawerAccount, onSelectDrawerAccount) + AccountSwitcher(accounts, drawerAccount, accountsWithUnread, onSelectDrawerAccount) NavigationDrawerItem( label = { Text(stringResource(R.string.folder_all_inboxes)) }, icon = { Icon(Icons.Filled.Email, contentDescription = null) }, @@ -87,9 +95,15 @@ fun FolderDrawer( val iconContent: (@Composable () -> Unit)? = folderIcon(folder.role)?.let { vector -> { Icon(vector, contentDescription = null) } } + val unread = folderUnreadCounts[folder.fullName] ?: 0 NavigationDrawerItem( label = { Text(resolvedLabels[folder.fullName] ?: folderDisplayLabel(folder)) }, icon = iconContent, + badge = if (unread > 0) { + { UnreadBadgeLabel(unread) } + } else { + null + }, selected = isSelected, onClick = { if (folder.selectable && drawerAccount != null) { @@ -103,19 +117,34 @@ fun FolderDrawer( } @Composable -private fun AccountSwitcher(accounts: List, current: Account, onSelect: (String) -> Unit) { +private fun AccountSwitcher( + accounts: List, + current: Account, + accountsWithUnread: Set, + onSelect: (String) -> Unit, +) { var expanded by remember { mutableStateOf(false) } TextButton( onClick = { expanded = true }, modifier = Modifier.padding(horizontal = 16.dp), ) { - Text(current.email, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + current.email, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (current.id in accountsWithUnread) FontWeight.Bold else FontWeight.Normal, + ) Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.drawer_switch_account)) } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { accounts.forEach { account -> DropdownMenuItem( - text = { Text(account.email) }, + text = { + Text( + account.email, + fontWeight = if (account.id in accountsWithUnread) FontWeight.Bold else FontWeight.Normal, + ) + }, onClick = { onSelect(account.id) expanded = false @@ -125,6 +154,29 @@ private fun AccountSwitcher(accounts: List, current: Account, onSelect: } } +/** Cap for a folder's visible unread badge; higher counts render as "99+" to keep the row compact. */ +private const val UNREAD_BADGE_CAP = 99 + +/** + * A folder row's trailing unread-count badge. The visible label is capped at [UNREAD_BADGE_CAP] as + * "99+" so a large count can't blow out the row, while the semantics announce the exact count as + * "N unread messages" (overriding the terse glyph) for screen readers. + */ +@Composable +private fun UnreadBadgeLabel(count: Int) { + val display = if (count > UNREAD_BADGE_CAP) { + stringResource(R.string.folder_unread_overflow) + } else { + count.toString() + } + val description = pluralStringResource(R.plurals.folder_unread_count_description, count, count) + Text( + text = display, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.clearAndSetSemantics { contentDescription = description }, + ) +} + /** * De-duplicated display labels for [folders], keyed by [Folder.fullName] — the one resolution used * by the drawer, the move-to picker, and the app-bar title, so a folder reads the same everywhere. 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 fc4f1aa..663aeaa 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -106,6 +106,8 @@ fun MailboxScreen( val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle() val selectedFolder by viewModel.selectedFolder.collectAsStateWithLifecycle() val folders by viewModel.folders.collectAsStateWithLifecycle() + val folderUnreadCounts by viewModel.folderUnreadCounts.collectAsStateWithLifecycle() + val accountsWithUnread by viewModel.accountsWithUnread.collectAsStateWithLifecycle() val drawerAccount by viewModel.drawerAccount.collectAsStateWithLifecycle() val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() val draftCount by viewModel.draftCount.collectAsStateWithLifecycle() @@ -156,6 +158,8 @@ fun MailboxScreen( accounts = accounts, drawerAccount = drawerAccount, folders = folders, + folderUnreadCounts = folderUnreadCounts, + accountsWithUnread = accountsWithUnread, selectedAccountId = selectedAccountId, selectedFolder = selectedFolder, onSelectUnifiedInbox = { @@ -256,6 +260,7 @@ fun MailboxScreen( AccountFilterRow( accounts = accounts, selectedId = selectedAccountId, + accountsWithUnread = accountsWithUnread, onSelect = viewModel::selectAccount, ) } @@ -411,7 +416,12 @@ private fun OutboxEntry(count: Int, onClick: () -> Unit) { } @Composable -private fun AccountFilterRow(accounts: List, selectedId: String?, onSelect: (String?) -> Unit) { +private fun AccountFilterRow( + accounts: List, + selectedId: String?, + accountsWithUnread: Set, + onSelect: (String?) -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() @@ -428,7 +438,13 @@ private fun AccountFilterRow(accounts: List, selectedId: String?, onSel FilterChip( selected = selectedId == account.id, onClick = { onSelect(account.id) }, - label = { Text(account.email, maxLines = 1) }, + label = { + Text( + account.email, + maxLines = 1, + fontWeight = if (account.id in accountsWithUnread) FontWeight.Bold else FontWeight.Normal, + ) + }, ) } } diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 180b9a4..d2d60fd 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -28,6 +28,7 @@ import org.libremail.domain.model.Folder import org.libremail.domain.model.FolderRole import org.libremail.domain.model.Message 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 org.libremail.ui.navigation.Routes @@ -87,6 +88,28 @@ class MailboxViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** + * Live unread counts across every account, shared by the two drawer indicators below so the + * underlying `COUNT` query is collected once no matter how many observers derive from it. + */ + private val unreadCounts: StateFlow> = mailRepository.observeUnreadCounts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** #83: unread-message count keyed by folder full name for the drawer account (absent = none). */ + val folderUnreadCounts: StateFlow> = + combine(drawerAccount, unreadCounts) { account, counts -> + if (account == null) { + emptyMap() + } else { + counts.filter { it.accountId == account.id }.associate { it.folder to it.count } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + + /** #84: ids of accounts that currently have unread mail in any folder. */ + val accountsWithUnread: StateFlow> = unreadCounts + .map { counts -> counts.filter { it.count > 0 }.map { it.accountId }.toSet() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet()) + private val _searchActive = MutableStateFlow(false) val searchActive: StateFlow = _searchActive.asStateFlow() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b713a0..75f6c41 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,13 @@ Archive Spam Trash + + 99+ + + + %1$d unread message + %1$d unread messages + From diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt index b73e4dd..02d2699 100644 --- a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt @@ -30,6 +30,7 @@ import org.libremail.domain.model.MailSecurity import org.libremail.domain.model.Message import org.libremail.domain.model.ReplyMode import org.libremail.domain.model.ServerConfig +import org.libremail.domain.model.UnreadCount import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository import org.libremail.ui.navigation.Routes @@ -429,10 +430,55 @@ class MailboxViewModelTest { assertEquals(listOf("imap:a:INBOX:1"), vm.messages.value.map { it.id }) } + @Test + fun `folderUnreadCounts maps the drawer account's folders to their unread counts`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = emptyList(), + unreadCounts = listOf( + UnreadCount("imap:a", "INBOX", 3), + UnreadCount("imap:a", "[Gmail]/Spam", 1), + UnreadCount("imap:b", "INBOX", 9), + ), + ) + backgroundScope.launch { vm.folderUnreadCounts.collect {} } + + // The drawer defaults to the first account (alice); bob's counts are excluded. + assertEquals(mapOf("INBOX" to 3, "[Gmail]/Spam" to 1), vm.folderUnreadCounts.value) + } + + @Test + fun `folderUnreadCounts follows the drawer-account switch`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = emptyList(), + unreadCounts = listOf(UnreadCount("imap:a", "INBOX", 3), UnreadCount("imap:b", "INBOX", 9)), + ) + backgroundScope.launch { vm.folderUnreadCounts.collect {} } + + vm.setDrawerAccount("imap:b") + + assertEquals(mapOf("INBOX" to 9), vm.folderUnreadCounts.value) + } + + @Test + fun `accountsWithUnread lists every account that has unread mail in any folder`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = emptyList(), + // alice has unread mail (in a non-inbox folder too); bob has none. + unreadCounts = listOf(UnreadCount("imap:a", "INBOX", 3), UnreadCount("imap:a", "Archive", 2)), + ) + backgroundScope.launch { vm.accountsWithUnread.collect {} } + + assertEquals(setOf("imap:a"), vm.accountsWithUnread.value) + } + private fun createViewModel( accounts: List, messages: List, folders: Map> = emptyMap(), + unreadCounts: List = emptyList(), syncer: MailSyncer = mockk(relaxed = true), repo: MailRepository = mockk(relaxed = true), initialAccountId: String? = null, @@ -440,6 +486,7 @@ class MailboxViewModelTest { every { repo.observeMessages() } returns MutableStateFlow(messages) every { repo.observeDrafts() } returns flowOf(emptyList()) every { repo.observeOutbox() } returns flowOf(emptyList()) + every { repo.observeUnreadCounts() } returns MutableStateFlow(unreadCounts) accounts.forEach { account -> every { repo.observeFolders(account.id) } returns MutableStateFlow(folders[account.id] ?: emptyList()) }