From 9b970af2d170c24ecf48f7f97c773c4a4d933f7f Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 02:37:17 -0500 Subject: [PATCH] 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 --- .../data/local/LibreMailDatabaseTest.kt | 28 +++++++++ .../kotlin/org/libremail/ui/Fakes.kt | 4 ++ .../libremail/ui/mailbox/FolderDrawerTest.kt | 27 ++++++++ .../org/libremail/data/local/Mappers.kt | 8 +++ .../libremail/data/local/dao/MessageDao.kt | 14 +++++ .../data/local/entity/FolderUnreadCount.kt | 9 +++ .../data/repository/MailRepositoryImpl.kt | 5 ++ .../org/libremail/domain/model/UnreadCount.kt | 9 +++ .../domain/repository/MailRepository.kt | 8 +++ .../org/libremail/ui/mailbox/FolderDrawer.kt | 62 +++++++++++++++++-- .../org/libremail/ui/mailbox/MailboxScreen.kt | 20 +++++- .../libremail/ui/mailbox/MailboxViewModel.kt | 23 +++++++ app/src/main/res/values/strings.xml | 7 +++ .../ui/mailbox/MailboxViewModelTest.kt | 47 ++++++++++++++ 14 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/local/entity/FolderUnreadCount.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/UnreadCount.kt 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 51e3704..88579f3 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()) }