home: move search behind a top-bar action, not an always-on bar

The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 23:34:54 +02:00
parent c53511196d
commit e6f503c02a
2 changed files with 61 additions and 44 deletions

View File

@@ -82,4 +82,7 @@ fun ShapedActionButton(
object ActionShapes {
/** Settings — a 4-sided cookie (rounded, scalloped square). */
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
}

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.ui.lists
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -47,11 +49,14 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -99,7 +104,13 @@ fun ListsScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var query by rememberSaveable { mutableStateOf("") }
var searchExpanded by rememberSaveable { mutableStateOf(false) }
var searchActive by rememberSaveable { mutableStateOf(false) }
val closeSearch = {
query = ""
searchActive = false
}
// System back closes search before leaving the screen.
BackHandler(enabled = searchActive, onBack = closeSearch)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -107,12 +118,20 @@ fun ListsScreen(
MediumTopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
ShapedActionButton(
shape = ActionShapes.Search,
icon = Icons.Rounded.Search,
contentDescription = stringResource(R.string.home_search_hint),
onClick = { searchActive = true },
size = 48.dp,
iconSize = 26.dp,
)
ShapedActionButton(
shape = ActionShapes.Settings,
icon = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(end = 8.dp),
modifier = Modifier.padding(start = 8.dp, end = 8.dp),
size = 48.dp,
iconSize = 26.dp,
)
@@ -122,7 +141,7 @@ fun ListsScreen(
},
floatingActionButton = {
// The FAB would otherwise float over the expanded search results.
if (!searchExpanded) {
if (!searchActive) {
ExtendedFloatingActionButton(
onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
@@ -131,9 +150,6 @@ fun ListsScreen(
}
},
) { 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) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
@@ -144,14 +160,16 @@ fun ListsScreen(
state = s,
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
topPadding = SEARCH_BAR_CLEARANCE,
topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
)
// Only present while searching — it expands to cover the home
// content with live results, and is absent otherwise.
if (searchActive) {
HomeSearchBar(
query = query,
onQueryChange = { query = it },
expanded = searchExpanded,
onExpandedChange = { searchExpanded = it },
onClose = closeSearch,
allTasks = s.allTasks,
onOpenTask = onOpenTask,
modifier = Modifier.align(Alignment.TopCenter),
@@ -161,9 +179,7 @@ fun ListsScreen(
}
}
}
/** Top inset the home content reserves for the collapsed search bar. */
private val SEARCH_BAR_CLEARANCE = 72.dp
}
@Composable
private fun ListsContent(
@@ -239,17 +255,17 @@ 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.
* The home search: a Material 3 SearchBar shown only while searching (triggered by
* the top-bar search action). It opens expanded and focused, covering the home
* content with tasks whose title matches the query, across every list (completed
* included). The back arrow or system back closes it via [onClose].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeSearchBar(
query: String,
onQueryChange: (String) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onClose: () -> Unit,
allTasks: List<Task>,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
@@ -261,36 +277,34 @@ private fun HomeSearchBar(
.filter { it.title.contains(q, ignoreCase = true) }
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
}
val collapse = {
onQueryChange("")
onExpandedChange(false)
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
SearchBar(
modifier = modifier.fillMaxWidth().padding(horizontal = if (expanded) 0.dp else 16.dp),
expanded = expanded,
onExpandedChange = onExpandedChange,
modifier = modifier.fillMaxWidth(),
expanded = true,
onExpandedChange = { if (!it) onClose() },
// The enclosing app bar already consumes the status-bar inset; without this
// the search bar would add it again and float with a large top gap.
windowInsets = WindowInsets(0),
inputField = {
SearchBarDefaults.InputField(
modifier = Modifier.focusRequester(focusRequester),
query = query,
onQueryChange = onQueryChange,
onSearch = {},
expanded = expanded,
onExpandedChange = onExpandedChange,
expanded = true,
onExpandedChange = { if (!it) onClose() },
placeholder = { Text(stringResource(R.string.home_search_hint)) },
leadingIcon = {
if (expanded) {
IconButton(onClick = collapse) {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.home_search_close),
)
}
} else {
Icon(Icons.Rounded.Search, contentDescription = null)
}
},
trailingIcon = {
if (expanded && query.isNotEmpty()) {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
Icons.Rounded.Close,
@@ -315,7 +329,7 @@ private fun HomeSearchBar(
UpcomingRow(
task = task,
onClick = {
collapse()
onClose()
onOpenTask(task.taskId)
},
)