Merge pull request #116 from JMR-dev/feat-drawer-unread-indicators
feat(drawer): unread-count badges per folder and bold accounts with unread mail
This commit was merged in pull request #116.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<List<MessageSummary>>
|
||||
|
||||
/**
|
||||
* 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<List<FolderUnreadCount>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
|
||||
suspend fun getById(id: String): MessageEntity?
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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<List<UnreadCount>> = messageDao.observeUnreadCounts().map { rows ->
|
||||
rows.map { it.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun refreshFolders(accountId: String): Result<Unit> = runCatching {
|
||||
val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found")
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
|
||||
@@ -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)
|
||||
@@ -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<List<Folder>>
|
||||
|
||||
/**
|
||||
* 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<List<UnreadCount>>
|
||||
|
||||
/** Refreshes the account's folder list from the server into the cache. */
|
||||
suspend fun refreshFolders(accountId: String): Result<Unit>
|
||||
|
||||
|
||||
@@ -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<Account>,
|
||||
drawerAccount: Account?,
|
||||
folders: List<Folder>,
|
||||
folderUnreadCounts: Map<String, Int>,
|
||||
accountsWithUnread: Set<String>,
|
||||
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<Account>, current: Account, onSelect: (String) -> Unit) {
|
||||
private fun AccountSwitcher(
|
||||
accounts: List<Account>,
|
||||
current: Account,
|
||||
accountsWithUnread: Set<String>,
|
||||
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<Account>, 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.
|
||||
|
||||
@@ -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<Account>, selectedId: String?, onSelect: (String?) -> Unit) {
|
||||
private fun AccountFilterRow(
|
||||
accounts: List<Account>,
|
||||
selectedId: String?,
|
||||
accountsWithUnread: Set<String>,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -428,7 +438,13 @@ private fun AccountFilterRow(accounts: List<Account>, 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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<UnreadCount>> = 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<Map<String, Int>> =
|
||||
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<Set<String>> = 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<Boolean> = _searchActive.asStateFlow()
|
||||
|
||||
|
||||
@@ -68,6 +68,13 @@
|
||||
<string name="folder_archive">Archive</string>
|
||||
<string name="folder_spam">Spam</string>
|
||||
<string name="folder_trash">Trash</string>
|
||||
<!-- Unread badge shown after a folder name; caps very large counts. -->
|
||||
<string name="folder_unread_overflow">99+</string>
|
||||
<!-- Screen-reader description for a folder's unread badge, e.g. "12 unread messages". -->
|
||||
<plurals name="folder_unread_count_description">
|
||||
<item quantity="one">%1$d unread message</item>
|
||||
<item quantity="other">%1$d unread messages</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Compose -->
|
||||
<string name="compose_from">From</string>
|
||||
|
||||
@@ -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<Account>,
|
||||
messages: List<Message>,
|
||||
folders: Map<String, List<Folder>> = emptyMap(),
|
||||
unreadCounts: List<UnreadCount> = 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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user