From fcd84fb93382aebdea75b505577596f3412aecfa Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 15:20:19 -0500 Subject: [PATCH] Add inbox search over cached mail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 4 +- .../org/libremail/ui/mailbox/MailboxScreen.kt | 88 ++++++++++++++++++- .../libremail/ui/mailbox/MailboxViewModel.kt | 34 ++++++- app/src/main/res/values/strings.xml | 4 + 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da32af8..90b0a17 100644 --- a/README.md +++ b/README.md @@ -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) 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 840e576..796f0bd 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -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, 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, 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 7b80c05..0ecb837 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -37,9 +37,19 @@ class MailboxViewModel @Inject constructor( private val _selectedAccountId = MutableStateFlow(null) val selectedAccountId: StateFlow = _selectedAccountId.asStateFlow() + private val _searchActive = MutableStateFlow(false) + val searchActive: StateFlow = _searchActive.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + val messages: StateFlow> = - 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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c28e48b..a9adc19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,10 @@ No messages yet All + Search + Close search + Search mail + No results Pull down to refresh Welcome to LibreMail Add an account to start reading your mail.