From ee0bbacb9851345ca5a3b4583aeaf68b09d37da8 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 13:16:49 -0500 Subject: [PATCH] Add multi-account: unified inbox with per-account filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data layer, background sync, and IDLE already handled N accounts; this makes the UI account-aware. - Mailbox: filter chips (All + one per account) appear once 2+ accounts exist, and each message in the unified view is labelled with its account. The filter resets to All if the selected account is removed. - Reply now carries the receiving account through to compose, so From defaults to the account that received the message rather than just the first account. - Removing an account now also deletes its cached messages and attachments, so they leave the unified inbox. - assemble/test/lint green; verified on the Android 17 emulator — added a second GreenMail account, saw both accounts' mail unified + attributed, filtered to one account, and replied from the correct account. Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +- .../libremail/data/local/dao/AttachmentDao.kt | 4 + .../data/repository/AccountRepositoryImpl.kt | 7 ++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 3 +- .../libremail/ui/compose/ComposeViewModel.kt | 1 + .../org/libremail/ui/mailbox/MailboxScreen.kt | 80 ++++++++++++++++--- .../libremail/ui/mailbox/MailboxViewModel.kt | 37 +++++++-- .../org/libremail/ui/navigation/Routes.kt | 7 +- .../org/libremail/ui/reader/ReaderScreen.kt | 4 +- app/src/main/res/values/strings.xml | 1 + 10 files changed, 121 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 85e4a1e..da32af8 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ experience with power-user features tucked under an **Advanced Settings** group. > with mark-read, star, and delete; and **composing** — a compose screen with device- > contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail > notifications** (no push service) with persisted settings; **instant push** via a -> foreground **IMAP IDLE** service; and **attachments** — downloaded on demand and opened -> in a system viewer. Outlook Graph send and multi-account polish are upcoming. +> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a +> system viewer; and **multiple accounts** — a unified inbox with per-account filtering. +> Outlook Graph send and search are upcoming. ## Features (target MVP) diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt index b016c6e..1899b9e 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt @@ -20,6 +20,10 @@ interface AttachmentDao { @Query("DELETE FROM attachments WHERE messageId = :messageId") suspend fun deleteForMessage(messageId: String) + /** Deletes attachment rows for every message of an account (ids are "accountId:uid"). */ + @Query("DELETE FROM attachments WHERE messageId LIKE :accountPrefix") + suspend fun deleteByAccountPrefix(accountPrefix: String) + /** Replaces the cached attachment list for a message in one transaction. */ @Transaction suspend fun replaceForMessage(messageId: String, attachments: List) { diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index 7fc087e..2f50cca 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -6,6 +6,8 @@ import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.AttachmentDao +import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity import org.libremail.data.local.toImapParams @@ -19,6 +21,8 @@ import org.libremail.mail.ImapClient @Singleton class AccountRepositoryImpl @Inject constructor( private val accountDao: AccountDao, + private val messageDao: MessageDao, + private val attachmentDao: AttachmentDao, private val credentialStore: CredentialStore, private val imapClient: ImapClient, private val syncScheduler: SyncScheduler, @@ -54,5 +58,8 @@ class AccountRepositoryImpl @Inject constructor( override suspend fun deleteAccount(id: String) { accountDao.deleteById(id) credentialStore.delete(id) + // Remove the account's cached mail so it disappears from the (unified) inbox. + attachmentDao.deleteByAccountPrefix("$id:%") + messageDao.deleteByAccount(id) } } diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 06aac4b..8c9d7e0 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -55,7 +55,7 @@ fun LibreMailApp() { ) { ReaderScreen( onBack = navController::popBackStack, - onReply = { to, subject -> navController.navigate(Routes.compose(to, subject)) }, + onReply = { to, subject, from -> navController.navigate(Routes.compose(to, subject, from)) }, ) } composable( @@ -63,6 +63,7 @@ fun LibreMailApp() { arguments = listOf( navArgument(Routes.COMPOSE_ARG_TO) { type = NavType.StringType; defaultValue = "" }, navArgument(Routes.COMPOSE_ARG_SUBJECT) { type = NavType.StringType; defaultValue = "" }, + navArgument(Routes.COMPOSE_ARG_FROM) { type = NavType.StringType; defaultValue = "" }, ), ) { ComposeScreen(onBack = navController::popBackStack) diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 764f1a7..1f8e4c8 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -47,6 +47,7 @@ class ComposeViewModel @Inject constructor( ComposeUiState( to = savedStateHandle.get(Routes.COMPOSE_ARG_TO).orEmpty(), subject = savedStateHandle.get(Routes.COMPOSE_ARG_SUBJECT).orEmpty(), + fromAccountId = savedStateHandle.get(Routes.COMPOSE_ARG_FROM)?.takeIf { it.isNotBlank() }, ), ) val state: StateFlow = _state.asStateFlow() 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 1498beb..840e576 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -4,6 +4,7 @@ package org.libremail.ui.mailbox import android.text.format.DateUtils import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -24,6 +26,7 @@ import androidx.compose.material.icons.filled.Email import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -47,6 +50,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R +import org.libremail.domain.model.Account import org.libremail.domain.model.Message @OptIn(ExperimentalMaterial3Api::class) @@ -59,6 +63,8 @@ fun MailboxScreen( viewModel: MailboxViewModel = hiltViewModel(), ) { val messages by viewModel.messages.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle() val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() @@ -92,18 +98,33 @@ fun MailboxScreen( if (!hasAccounts) { NoAccountState(onAddAccount = onAddAccount) } else { - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier.fillMaxSize(), - ) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - if (messages.isEmpty()) { - item { NoMessagesState(Modifier.fillParentMaxSize()) } - } else { - items(messages, key = { it.id }) { message -> - MessageRow(message = message, onClick = { onOpenMessage(message.id) }) - HorizontalDivider() + val accountsById = remember(accounts) { accounts.associateBy { it.id } } + val showAccount = selectedAccountId == null && accounts.size >= 2 + Column(Modifier.fillMaxSize()) { + if (accounts.size >= 2) { + AccountFilterRow( + accounts = accounts, + selectedId = selectedAccountId, + onSelect = viewModel::selectAccount, + ) + } + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (messages.isEmpty()) { + item { NoMessagesState(Modifier.fillParentMaxSize()) } + } else { + items(messages, key = { it.id }) { message -> + MessageRow( + message = message, + accountLabel = if (showAccount) accountsById[message.accountId]?.email else null, + onClick = { onOpenMessage(message.id) }, + ) + HorizontalDivider() + } } } } @@ -114,7 +135,31 @@ fun MailboxScreen( } @Composable -private fun MessageRow(message: Message, onClick: () -> Unit) { +private fun AccountFilterRow(accounts: List, selectedId: String?, onSelect: (String?) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedId == null, + onClick = { onSelect(null) }, + label = { Text(stringResource(R.string.mailbox_all_accounts)) }, + ) + accounts.forEach { account -> + FilterChip( + selected = selectedId == account.id, + onClick = { onSelect(account.id) }, + label = { Text(account.email, maxLines = 1) }, + ) + } + } +} + +@Composable +private fun MessageRow(message: Message, accountLabel: String?, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -157,6 +202,15 @@ private fun MessageRow(message: Message, onClick: () -> Unit) { overflow = TextOverflow.Ellipsis, ) } + if (accountLabel != null) { + Text( + text = accountLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } 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 55419d5..7b80c05 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -9,10 +9,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.libremail.data.sync.MailSyncer +import org.libremail.domain.model.Account import org.libremail.domain.model.Message import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @@ -24,14 +26,21 @@ class MailboxViewModel @Inject constructor( private val mailSyncer: MailSyncer, ) : ViewModel() { - val messages: StateFlow> = - mailRepository.observeMessages() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val accounts: StateFlow> = accountRepository.observeAccounts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val hasAccounts: StateFlow = - accountRepository.observeAccounts() - .map { it.isNotEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val hasAccounts: StateFlow = accounts + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + /** null = unified "All inboxes"; otherwise the account whose mail is shown. */ + private val _selectedAccountId = MutableStateFlow(null) + val selectedAccountId: StateFlow = _selectedAccountId.asStateFlow() + + val messages: StateFlow> = + combine(mailRepository.observeMessages(), _selectedAccountId) { all, accountId -> + if (accountId == null) all else all.filter { it.accountId == accountId } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() @@ -39,6 +48,20 @@ class MailboxViewModel @Inject constructor( private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + init { + // Fall back to the unified view if the filtered account is removed. + viewModelScope.launch { + accounts.collect { list -> + val selected = _selectedAccountId.value + if (selected != null && list.none { it.id == selected }) _selectedAccountId.value = null + } + } + } + + fun selectAccount(accountId: String?) { + _selectedAccountId.value = accountId + } + fun refresh() { if (_isRefreshing.value) return viewModelScope.launch { diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index ccef1f5..7068ea7 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -16,7 +16,8 @@ object Routes { const val COMPOSE_ARG_TO = "to" const val COMPOSE_ARG_SUBJECT = "subject" - const val COMPOSE_PATTERN = "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}" - fun compose(to: String = "", subject: String = ""): String = - "compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}" + const val COMPOSE_ARG_FROM = "from" + const val COMPOSE_PATTERN = "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}" + fun compose(to: String = "", subject: String = "", from: String = ""): String = + "compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}&from=${Uri.encode(from)}" } diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt index 6c0c9b0..89fbf94 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -61,7 +61,7 @@ import org.libremail.domain.model.Message @Composable fun ReaderScreen( onBack: () -> Unit, - onReply: (to: String, subject: String) -> Unit, + onReply: (to: String, subject: String, from: String) -> Unit, viewModel: ReaderViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -101,7 +101,7 @@ fun ReaderScreen( actions = { val message = state.message if (message != null) { - TextButton(onClick = { onReply(message.senderEmail, "Re: ${message.subject}") }) { + TextButton(onClick = { onReply(message.senderEmail, "Re: ${message.subject}", message.accountId) }) { Text(stringResource(R.string.reader_reply)) } IconButton(onClick = viewModel::toggleStar) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e65313..c28e48b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ No messages yet + All Pull down to refresh Welcome to LibreMail Add an account to start reading your mail.