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 { object ActionShapes {
/** Settings — a 4-sided cookie (rounded, scalloped square). */ /** Settings — a 4-sided cookie (rounded, scalloped square). */
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided 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 package de.jeanlucmakiola.agendula.ui.lists
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -47,11 +49,14 @@ 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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -99,7 +104,13 @@ 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 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( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -107,12 +118,20 @@ fun ListsScreen(
MediumTopAppBar( MediumTopAppBar(
title = { Text(stringResource(R.string.app_name)) }, title = { Text(stringResource(R.string.app_name)) },
actions = { 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( ShapedActionButton(
shape = ActionShapes.Settings, shape = ActionShapes.Settings,
icon = Icons.Rounded.Settings, icon = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title), contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings, onClick = onOpenSettings,
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(start = 8.dp, end = 8.dp),
size = 48.dp, size = 48.dp,
iconSize = 26.dp, iconSize = 26.dp,
) )
@@ -122,7 +141,7 @@ fun ListsScreen(
}, },
floatingActionButton = { floatingActionButton = {
// The FAB would otherwise float over the expanded search results. // The FAB would otherwise float over the expanded search results.
if (!searchExpanded) { if (!searchActive) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewTask, onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
@@ -131,9 +150,6 @@ fun ListsScreen(
} }
}, },
) { 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())) { 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
@@ -144,27 +160,27 @@ fun ListsScreen(
state = s, state = s,
onOpenFilter = onOpenFilter, onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask, onOpenTask = onOpenTask,
topPadding = SEARCH_BAR_CLEARANCE, topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp,
) )
HomeSearchBar( // Only present while searching — it expands to cover the home
query = query, // content with live results, and is absent otherwise.
onQueryChange = { query = it }, if (searchActive) {
expanded = searchExpanded, HomeSearchBar(
onExpandedChange = { searchExpanded = it }, query = query,
allTasks = s.allTasks, onQueryChange = { query = it },
onOpenTask = onOpenTask, onClose = closeSearch,
modifier = Modifier.align(Alignment.TopCenter), 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,
@@ -239,17 +255,17 @@ private fun ListsContent(
} }
/** /**
* The home search: a Material 3 SearchBar that expands in place to show tasks whose * The home search: a Material 3 SearchBar shown only while searching (triggered by
* title matches the query, across every list (completed included). The leading icon * the top-bar search action). It opens expanded and focused, covering the home
* flips to a back arrow while expanded; collapsing clears the field. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun HomeSearchBar( private fun HomeSearchBar(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
expanded: Boolean, onClose: () -> Unit,
onExpandedChange: (Boolean) -> Unit,
allTasks: List<Task>, allTasks: List<Task>,
onOpenTask: (Long) -> Unit, onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -261,36 +277,34 @@ private fun HomeSearchBar(
.filter { it.title.contains(q, ignoreCase = true) } .filter { it.title.contains(q, ignoreCase = true) }
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() })) .sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
} }
val collapse = { val focusRequester = remember { FocusRequester() }
onQueryChange("") LaunchedEffect(Unit) { focusRequester.requestFocus() }
onExpandedChange(false)
}
SearchBar( SearchBar(
modifier = modifier.fillMaxWidth().padding(horizontal = if (expanded) 0.dp else 16.dp), modifier = modifier.fillMaxWidth(),
expanded = expanded, expanded = true,
onExpandedChange = onExpandedChange, 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 = { inputField = {
SearchBarDefaults.InputField( SearchBarDefaults.InputField(
modifier = Modifier.focusRequester(focusRequester),
query = query, query = query,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
onSearch = {}, onSearch = {},
expanded = expanded, expanded = true,
onExpandedChange = onExpandedChange, onExpandedChange = { if (!it) onClose() },
placeholder = { Text(stringResource(R.string.home_search_hint)) }, placeholder = { Text(stringResource(R.string.home_search_hint)) },
leadingIcon = { leadingIcon = {
if (expanded) { IconButton(onClick = onClose) {
IconButton(onClick = collapse) { Icon(
Icon( Icons.AutoMirrored.Rounded.ArrowBack,
Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.home_search_close),
contentDescription = stringResource(R.string.home_search_close), )
)
}
} else {
Icon(Icons.Rounded.Search, contentDescription = null)
} }
}, },
trailingIcon = { trailingIcon = {
if (expanded && query.isNotEmpty()) { if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) { IconButton(onClick = { onQueryChange("") }) {
Icon( Icon(
Icons.Rounded.Close, Icons.Rounded.Close,
@@ -315,7 +329,7 @@ private fun HomeSearchBar(
UpcomingRow( UpcomingRow(
task = task, task = task,
onClick = { onClick = {
collapse() onClose()
onOpenTask(task.taskId) onOpenTask(task.taskId)
}, },
) )