Add inbox search over cached mail
- A search icon in the inbox app bar opens an in-bar search field (autofocused, with a Back/close handler); typing filters the message list by sender, address, subject, and snippet (case-insensitive), within the current account filter. - Filtering is reactive over the cached list, so results update live as mail syncs, and a "No results" state shows when nothing matches. - assemble/test/lint green; verified on the Android 17 emulator — searching "IMAP" narrowed three messages to the two whose subject matched, and a non-matching query showed the empty state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,8 @@ experience with power-user features tucked under an **Advanced Settings** group.
|
||||
> 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; **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.
|
||||
> system viewer; **multiple accounts** — a unified inbox with per-account filtering; and
|
||||
> **search** across cached mail. Outlook Graph send is upcoming.
|
||||
|
||||
## Features (target MVP)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package org.libremail.ui.mailbox
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
@@ -20,15 +21,19 @@ 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.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -43,6 +48,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -66,10 +74,14 @@ fun MailboxScreen(
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle()
|
||||
val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle()
|
||||
val searchActive by viewModel.searchActive.collectAsStateWithLifecycle()
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
BackHandler(enabled = searchActive) { viewModel.closeSearch() }
|
||||
|
||||
LaunchedEffect(error) {
|
||||
error?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
@@ -78,7 +90,31 @@ fun MailboxScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text(stringResource(R.string.title_mailbox)) }) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (searchActive) {
|
||||
SearchField(query = searchQuery, onQueryChange = viewModel::onSearchQuery)
|
||||
} else {
|
||||
Text(stringResource(R.string.title_mailbox))
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (searchActive) {
|
||||
IconButton(onClick = viewModel::closeSearch) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_close))
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (hasAccounts && !searchActive) {
|
||||
IconButton(onClick = viewModel::openSearch) {
|
||||
Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
org.libremail.ui.LibreMailBottomBar(
|
||||
current = org.libremail.ui.TopDest.MAILBOX,
|
||||
@@ -115,7 +151,13 @@ fun MailboxScreen(
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
if (messages.isEmpty()) {
|
||||
item { NoMessagesState(Modifier.fillParentMaxSize()) }
|
||||
item {
|
||||
if (searchActive && searchQuery.isNotBlank()) {
|
||||
NoResultsState(Modifier.fillParentMaxSize())
|
||||
} else {
|
||||
NoMessagesState(Modifier.fillParentMaxSize())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
MessageRow(
|
||||
@@ -134,6 +176,30 @@ fun MailboxScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchField(query: String, onQueryChange: (String) -> Unit) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
decorationBox = { innerTextField ->
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.search_hint),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
},
|
||||
)
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountFilterRow(accounts: List<Account>, selectedId: String?, onSelect: (String?) -> Unit) {
|
||||
Row(
|
||||
@@ -284,6 +350,24 @@ private fun NoMessagesState(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoResultsState(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(stringResource(R.string.search_no_results), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(millis: Long): String =
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
millis,
|
||||
|
||||
@@ -37,9 +37,19 @@ class MailboxViewModel @Inject constructor(
|
||||
private val _selectedAccountId = MutableStateFlow<String?>(null)
|
||||
val selectedAccountId: StateFlow<String?> = _selectedAccountId.asStateFlow()
|
||||
|
||||
private val _searchActive = MutableStateFlow(false)
|
||||
val searchActive: StateFlow<Boolean> = _searchActive.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val messages: StateFlow<List<Message>> =
|
||||
combine(mailRepository.observeMessages(), _selectedAccountId) { all, accountId ->
|
||||
if (accountId == null) all else all.filter { it.accountId == accountId }
|
||||
combine(mailRepository.observeMessages(), _selectedAccountId, _searchQuery) { all, accountId, query ->
|
||||
val q = query.trim()
|
||||
all.filter { message ->
|
||||
(accountId == null || message.accountId == accountId) &&
|
||||
(q.isEmpty() || message.matchesSearch(q))
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
@@ -62,6 +72,19 @@ class MailboxViewModel @Inject constructor(
|
||||
_selectedAccountId.value = accountId
|
||||
}
|
||||
|
||||
fun openSearch() {
|
||||
_searchActive.value = true
|
||||
}
|
||||
|
||||
fun closeSearch() {
|
||||
_searchActive.value = false
|
||||
_searchQuery.value = ""
|
||||
}
|
||||
|
||||
fun onSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (_isRefreshing.value) return
|
||||
viewModelScope.launch {
|
||||
@@ -75,3 +98,10 @@ class MailboxViewModel @Inject constructor(
|
||||
_error.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Local match over the always-populated header fields (and snippet, once a body is cached). */
|
||||
private fun Message.matchesSearch(query: String): Boolean =
|
||||
sender.contains(query, ignoreCase = true) ||
|
||||
senderEmail.contains(query, ignoreCase = true) ||
|
||||
subject.contains(query, ignoreCase = true) ||
|
||||
snippet.contains(query, ignoreCase = true)
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<!-- Mailbox -->
|
||||
<string name="mailbox_empty">No messages yet</string>
|
||||
<string name="mailbox_all_accounts">All</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="search_close">Close search</string>
|
||||
<string name="search_hint">Search mail</string>
|
||||
<string name="search_no_results">No results</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