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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +160,16 @@ 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,
|
||||||
)
|
)
|
||||||
|
// Only present while searching — it expands to cover the home
|
||||||
|
// content with live results, and is absent otherwise.
|
||||||
|
if (searchActive) {
|
||||||
HomeSearchBar(
|
HomeSearchBar(
|
||||||
query = query,
|
query = query,
|
||||||
onQueryChange = { query = it },
|
onQueryChange = { query = it },
|
||||||
expanded = searchExpanded,
|
onClose = closeSearch,
|
||||||
onExpandedChange = { searchExpanded = it },
|
|
||||||
allTasks = s.allTasks,
|
allTasks = s.allTasks,
|
||||||
onOpenTask = onOpenTask,
|
onOpenTask = onOpenTask,
|
||||||
modifier = Modifier.align(Alignment.TopCenter),
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
@@ -160,11 +178,9 @@ fun ListsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user