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:
2026-06-27 15:20:19 -05:00
co-authored by Claude Opus 4.8
parent 1d918cdd75
commit fcd84fb933
4 changed files with 124 additions and 6 deletions
+2 -2
View File
@@ -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)
+4
View File
@@ -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>