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:
@@ -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 = {
|
||||||
ExtendedFloatingActionButton(
|
// The FAB would otherwise float over the expanded search results.
|
||||||
onClick = onNewTask,
|
if (!searchExpanded) {
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
ExtendedFloatingActionButton(
|
||||||
text = { Text(stringResource(R.string.new_task)) },
|
onClick = onNewTask,
|
||||||
)
|
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||||
|
text = { Text(stringResource(R.string.new_task)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
when (val s = state) {
|
// The search bar overlays the top of the content; when expanded it grows to
|
||||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
// cover the home content with live results. The content's own top padding
|
||||||
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner)
|
// clears the collapsed bar so the hero isn't hidden behind it.
|
||||||
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter, onOpenTask)
|
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
|
||||||
|
when (val s = state) {
|
||||||
|
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
|
ListsUiState.Failure ->
|
||||||
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user