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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user