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