Add multi-account: unified inbox with per-account filter

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 13:16:49 -05:00
co-authored by Claude Opus 4.8
parent 6d3360fdb9
commit ee0bbacb98
10 changed files with 121 additions and 28 deletions
@@ -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<AttachmentEntity>) {
@@ -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)
}
}
@@ -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)
@@ -47,6 +47,7 @@ class ComposeViewModel @Inject constructor(
ComposeUiState(
to = savedStateHandle.get<String>(Routes.COMPOSE_ARG_TO).orEmpty(),
subject = savedStateHandle.get<String>(Routes.COMPOSE_ARG_SUBJECT).orEmpty(),
fromAccountId = savedStateHandle.get<String>(Routes.COMPOSE_ARG_FROM)?.takeIf { it.isNotBlank() },
),
)
val state: StateFlow<ComposeUiState> = _state.asStateFlow()
@@ -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<Account>, 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,
)
}
}
}
}
@@ -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<List<Message>> =
mailRepository.observeMessages()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val hasAccounts: StateFlow<Boolean> =
accountRepository.observeAccounts()
.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val hasAccounts: StateFlow<Boolean> = 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<String?>(null)
val selectedAccountId: StateFlow<String?> = _selectedAccountId.asStateFlow()
val messages: StateFlow<List<Message>> =
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<Boolean> = _isRefreshing.asStateFlow()
@@ -39,6 +48,20 @@ class MailboxViewModel @Inject constructor(
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _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 {
@@ -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)}"
}
@@ -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) {
+1
View File
@@ -24,6 +24,7 @@
<!-- Mailbox -->
<string name="mailbox_empty">No messages yet</string>
<string name="mailbox_all_accounts">All</string>
<string name="mailbox_pull_to_refresh">Pull down to refresh</string>
<string name="mailbox_welcome_title">Welcome to LibreMail</string>
<string name="mailbox_welcome_subtitle">Add an account to start reading your mail.</string>