Two related drawer-UX tickets, shipped together because they share one unread-count data source.
#83 — unread count per folder. Each folder row in the navigation drawer shows a trailing badge with its unread count, hidden when zero and capped at 99+. The badge is a NavigationDrawerItembadge slot.
#84 — bold accounts with unread mail. Accounts that have unread mail render their email in bold in the drawer account switcher (button label + dropdown rows) and in the mailbox account-filter chips, matching the FontWeight.Bold convention MessageRow already uses for unread messages.
How the unread count is queried and plumbed
One efficient Room aggregate is the single source for both features:
MessageDao.observeUnreadCounts(): Flow<List<FolderUnreadCount>> — a pure COUNT(*) aggregate that pulls no message rows into memory. Only folder-synced rows (inInbox = 1) count, so transient server-search hits never inflate a badge.
No schema change / migration. The GROUP BY accountId, folder is served by the existing (accountId, folder, uid) index's leftmost prefix, so nothing new was added to the schema.
MailRepository.observeUnreadCounts() maps the projection to a domain UnreadCount(accountId, folder, count).
MailboxViewModel collects it once (unreadCounts) and derives two states from it:
folderUnreadCounts: StateFlow<Map<String, Int>> — folder full-name → count for the drawer account (#83).
accountsWithUnread: StateFlow<Set<String>> — account ids with unread mail in any folder (#84).
"Unread" means folder-synced unread mail in any folder, kept consistent across both features: an account reads as bold exactly when one of its folders would show a badge. #83 inherently shows a badge on every folder that has unread mail, so scoping #84 to inbox-only would have been inconsistent with what the drawer already displays. Both consume the one query, as the tickets suggested.
Accessibility
The badge's visible glyph is terse (12, or 99+), so its semantics are overridden with a plurals content description — e.g. TalkBack announces "Inbox, 12 unread messages" — via clearAndSetSemantics.
Tests
JVM (MailboxViewModelTest):folderUnreadCounts maps only the drawer account's folders; it follows a drawer-account switch; accountsWithUnread lists every account with unread mail (any folder).
Instrumented (LibreMailDatabaseTest):observeUnreadCounts aggregates unread synced rows per (account, folder), excludes read rows and inInbox = 0 search hits.
Instrumented (FolderDrawerTest): a folder with unread mail shows its count badge (by content description); a read folder shows none.
Fast gate green locally: assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt, and compileDebugAndroidTestKotlin.
Notes for the maintainer
Deliberately stayed clear of the label-disambiguation resolver internals (#59–61) and did not implement drawer memoization/perf (#67) — the badge lookups ride the existing per-recomposition label resolution, which #67 will address.
## What
Two related drawer-UX tickets, shipped together because they share one unread-count data source.
- **#83 — unread count per folder.** Each folder row in the navigation drawer shows a trailing badge with its unread count, hidden when zero and capped at `99+`. The badge is a `NavigationDrawerItem` `badge` slot.
- **#84 — bold accounts with unread mail.** Accounts that have unread mail render their email in **bold** in the drawer account switcher (button label + dropdown rows) and in the mailbox account-filter chips, matching the `FontWeight.Bold` convention `MessageRow` already uses for unread messages.
## How the unread count is queried and plumbed
One efficient Room aggregate is the single source for both features:
```sql
SELECT accountId, folder, COUNT(*) AS unreadCount FROM messages
WHERE inInbox = 1 AND isRead = 0 GROUP BY accountId, folder
```
- `MessageDao.observeUnreadCounts(): Flow<List<FolderUnreadCount>>` — a pure `COUNT(*)` aggregate that pulls **no** message rows into memory. Only folder-synced rows (`inInbox = 1`) count, so transient server-search hits never inflate a badge.
- **No schema change / migration.** The `GROUP BY accountId, folder` is served by the existing `(accountId, folder, uid)` index's leftmost prefix, so nothing new was added to the schema.
- `MailRepository.observeUnreadCounts()` maps the projection to a domain `UnreadCount(accountId, folder, count)`.
- `MailboxViewModel` collects it once (`unreadCounts`) and derives two states from it:
- `folderUnreadCounts: StateFlow<Map<String, Int>>` — folder full-name → count for the **drawer account** (#83).
- `accountsWithUnread: StateFlow<Set<String>>` — account ids with unread mail in **any** folder (#84).
## Scope decision (resolves #84's open question)
"Unread" means folder-synced unread mail in **any** folder, kept consistent across both features: an account reads as bold exactly when one of its folders would show a badge. #83 inherently shows a badge on every folder that has unread mail, so scoping #84 to inbox-only would have been inconsistent with what the drawer already displays. Both consume the one query, as the tickets suggested.
## Accessibility
The badge's visible glyph is terse (`12`, or `99+`), so its semantics are overridden with a plurals content description — e.g. TalkBack announces "Inbox, 12 unread messages" — via `clearAndSetSemantics`.
## Tests
- **JVM (`MailboxViewModelTest`):** `folderUnreadCounts` maps only the drawer account's folders; it follows a drawer-account switch; `accountsWithUnread` lists every account with unread mail (any folder).
- **Instrumented (`LibreMailDatabaseTest`):** `observeUnreadCounts` aggregates unread synced rows per (account, folder), excludes read rows and `inInbox = 0` search hits.
- **Instrumented (`FolderDrawerTest`):** a folder with unread mail shows its count badge (by content description); a read folder shows none.
Fast gate green locally: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, and `compileDebugAndroidTestKotlin`.
## Notes for the maintainer
- Deliberately stayed clear of the label-disambiguation resolver internals (#59–61) and did **not** implement drawer memoization/perf (#67) — the badge lookups ride the existing per-recomposition label resolution, which #67 will address.
- Rebased on latest `origin/main` (through #113).
Closes #83
Closes #84
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Two related drawer-UX tickets, shipped together because they share one unread-count data source.
99+. The badge is aNavigationDrawerItembadgeslot.FontWeight.BoldconventionMessageRowalready uses for unread messages.How the unread count is queried and plumbed
One efficient Room aggregate is the single source for both features:
MessageDao.observeUnreadCounts(): Flow<List<FolderUnreadCount>>— a pureCOUNT(*)aggregate that pulls no message rows into memory. Only folder-synced rows (inInbox = 1) count, so transient server-search hits never inflate a badge.GROUP BY accountId, folderis served by the existing(accountId, folder, uid)index's leftmost prefix, so nothing new was added to the schema.MailRepository.observeUnreadCounts()maps the projection to a domainUnreadCount(accountId, folder, count).MailboxViewModelcollects it once (unreadCounts) and derives two states from it:folderUnreadCounts: StateFlow<Map<String, Int>>— folder full-name → count for the drawer account (#83).accountsWithUnread: StateFlow<Set<String>>— account ids with unread mail in any folder (#84).Scope decision (resolves #84's open question)
"Unread" means folder-synced unread mail in any folder, kept consistent across both features: an account reads as bold exactly when one of its folders would show a badge. #83 inherently shows a badge on every folder that has unread mail, so scoping #84 to inbox-only would have been inconsistent with what the drawer already displays. Both consume the one query, as the tickets suggested.
Accessibility
The badge's visible glyph is terse (
12, or99+), so its semantics are overridden with a plurals content description — e.g. TalkBack announces "Inbox, 12 unread messages" — viaclearAndSetSemantics.Tests
MailboxViewModelTest):folderUnreadCountsmaps only the drawer account's folders; it follows a drawer-account switch;accountsWithUnreadlists every account with unread mail (any folder).LibreMailDatabaseTest):observeUnreadCountsaggregates unread synced rows per (account, folder), excludes read rows andinInbox = 0search hits.FolderDrawerTest): a folder with unread mail shows its count badge (by content description); a read folder shows none.Fast gate green locally:
assembleDebug,testDebugUnitTest,lintDebug,ktlintCheck,detekt, andcompileDebugAndroidTestKotlin.Notes for the maintainer
origin/main(through #113).Closes #83
Closes #84
🤖 Generated with Claude Code