home: inline expanding search over all tasks

Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.

ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 23:07:12 +02:00
parent 411e27659f
commit a8595e26b4
3 changed files with 152 additions and 13 deletions

View File

@@ -18,13 +18,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ListAlt import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material.icons.rounded.Upcoming
@@ -33,15 +37,21 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -73,7 +83,8 @@ import java.time.ZoneId
* Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All * Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All
* 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's * 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's
* lists grouped by account. Tapping a tile opens that smart list, an upcoming row * lists grouped by account. Tapping a tile opens that smart list, an upcoming row
* opens that task, and the FAB starts a new task. * opens that task, and the FAB starts a new task. A search bar at the top expands
* in place to filter every task by title.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -87,6 +98,8 @@ fun ListsScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var query by rememberSaveable { mutableStateOf("") }
var searchExpanded by rememberSaveable { mutableStateOf(false) }
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -108,33 +121,63 @@ fun ListsScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
// The FAB would otherwise float over the expanded search results.
if (!searchExpanded) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewTask, onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) }, text = { Text(stringResource(R.string.new_task)) },
) )
}
}, },
) { inner -> ) { inner ->
// The search bar overlays the top of the content; when expanded it grows to
// cover the home content with live results. The content's own top padding
// clears the collapsed bar so the hero isn't hidden behind it.
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
when (val s = state) { when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner) ListsUiState.Failure ->
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter, onOpenTask) CenteredMessage(stringResource(R.string.lists_failure), PaddingValues(0.dp))
is ListsUiState.Content -> {
ListsContent(
state = s,
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
topPadding = SEARCH_BAR_CLEARANCE,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
)
HomeSearchBar(
query = query,
onQueryChange = { query = it },
expanded = searchExpanded,
onExpandedChange = { searchExpanded = it },
allTasks = s.allTasks,
onOpenTask = onOpenTask,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
} }
} }
} }
/** Top inset the home content reserves for the collapsed search bar. */
private val SEARCH_BAR_CLEARANCE = 72.dp
@Composable @Composable
private fun ListsContent( private fun ListsContent(
state: ListsUiState.Content, state: ListsUiState.Content,
inner: PaddingValues,
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit, onOpenTask: (Long) -> Unit,
topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: androidx.compose.ui.unit.Dp,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = inner.calculateTopPadding(), top = topPadding,
bottom = inner.calculateBottomPadding() + 96.dp, bottom = bottomPadding,
), ),
) { ) {
item { item {
@@ -195,6 +238,93 @@ private fun ListsContent(
} }
} }
/**
* The home search: a Material 3 SearchBar that expands in place to show tasks whose
* title matches the query, across every list (completed included). The leading icon
* flips to a back arrow while expanded; collapsing clears the field.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeSearchBar(
query: String,
onQueryChange: (String) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
allTasks: List<Task>,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val results = remember(query, allTasks) {
val q = query.trim()
if (q.isBlank()) emptyList()
else allTasks
.filter { it.title.contains(q, ignoreCase = true) }
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
}
val collapse = {
onQueryChange("")
onExpandedChange(false)
}
SearchBar(
modifier = modifier.fillMaxWidth().padding(horizontal = if (expanded) 0.dp else 16.dp),
expanded = expanded,
onExpandedChange = onExpandedChange,
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = onQueryChange,
onSearch = {},
expanded = expanded,
onExpandedChange = onExpandedChange,
placeholder = { Text(stringResource(R.string.home_search_hint)) },
leadingIcon = {
if (expanded) {
IconButton(onClick = collapse) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.home_search_close),
)
}
} else {
Icon(Icons.Rounded.Search, contentDescription = null)
}
},
trailingIcon = {
if (expanded && query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
Icons.Rounded.Close,
contentDescription = stringResource(R.string.home_search_clear),
)
}
}
},
)
},
) {
when {
query.isBlank() -> Unit
results.isEmpty() -> Text(
text = stringResource(R.string.home_search_empty, query.trim()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(results, key = { it.id }) { task ->
UpcomingRow(
task = task,
onClick = {
collapse()
onOpenTask(task.taskId)
},
)
}
}
}
}
}
/** /**
* The day's momentum: a wavy progress ring over "x of y done" for tasks due today. * The day's momentum: a wavy progress ring over "x of y done" for tasks due today.
* Tapping opens the Today list. When nothing is due today it drops the ring and * Tapping opens the Today list. When nothing is due today it drops the ring and

View File

@@ -34,6 +34,8 @@ sealed interface ListsUiState {
val todayTotal: Int, val todayTotal: Int,
/** The next few open tasks (due tomorrow onward) for the inline preview. */ /** The next few open tasks (due tomorrow onward) for the inline preview. */
val upcoming: List<Task>, val upcoming: List<Task>,
/** Every task (open and completed) — the corpus the home search filters. */
val allTasks: List<Task>,
) : ListsUiState ) : ListsUiState
} }
@@ -107,6 +109,7 @@ class ListsViewModel @Inject constructor(
todayDone = completedDueToday, todayDone = completedDueToday,
todayTotal = todayTotal, todayTotal = todayTotal,
upcoming = upcoming, upcoming = upcoming,
allTasks = openTasks + completedTasks,
) )
} }
} }

View File

@@ -140,6 +140,12 @@
<string name="home_due_today">Today</string> <string name="home_due_today">Today</string>
<string name="home_due_tomorrow">Tomorrow</string> <string name="home_due_tomorrow">Tomorrow</string>
<!-- Home: search -->
<string name="home_search_hint">Search tasks</string>
<string name="home_search_clear">Clear search</string>
<string name="home_search_close">Close search</string>
<string name="home_search_empty">No tasks match “%1$s”</string>
<!-- Reminders --> <!-- Reminders -->
<string name="reminder_due_at">Due %1$s</string> <string name="reminder_due_at">Due %1$s</string>
<string name="reminder_channel_name">Task reminders</string> <string name="reminder_channel_name">Task reminders</string>