home: unfurl search from a fixed search icon, drop the title

Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 00:00:42 +02:00
parent 721b411579
commit 6d3fbc05c1

View File

@@ -1,7 +1,13 @@
package de.jeanlucmakiola.agendula.ui.lists
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
@@ -14,16 +20,19 @@ 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
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.rounded.Add
import androidx.compose.material.icons.rounded.ChevronRight
@@ -35,19 +44,14 @@ import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
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
@@ -60,9 +64,10 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -88,10 +93,9 @@ import java.time.ZoneId
* 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
* lists grouped by account. Tapping a tile opens that smart list, an upcoming row
* 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.
* opens that task, and the FAB starts a new task. The search action in the top bar
* slides open into a full-width field that filters every task by title.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit,
@@ -102,7 +106,6 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val closeSearch = {
@@ -113,34 +116,18 @@ fun ListsScreen(
BackHandler(enabled = searchActive, onBack = closeSearch)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
modifier = modifier,
topBar = {
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(start = 8.dp, end = 8.dp),
size = 48.dp,
iconSize = 26.dp,
)
},
scrollBehavior = scrollBehavior,
HomeTopBar(
searchActive = searchActive,
query = query,
onQueryChange = { query = it },
onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
onOpenSettings = onOpenSettings,
)
},
floatingActionButton = {
// The FAB would otherwise float over the expanded search results.
// The FAB would otherwise float over the search results.
if (!searchActive) {
ExtendedFloatingActionButton(
onClick = onNewTask,
@@ -163,16 +150,18 @@ fun ListsScreen(
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.
// While searching with a non-blank query, results cover the home
// content; an empty query leaves the home content visible behind
// the open field.
if (searchActive) {
HomeSearchBar(
SearchResults(
query = query,
onQueryChange = { query = it },
onClose = closeSearch,
allTasks = s.allTasks,
onOpenTask = onOpenTask,
modifier = Modifier.align(Alignment.TopCenter),
onOpenTask = { taskId ->
closeSearch()
onOpenTask(taskId)
},
modifier = Modifier.fillMaxSize(),
)
}
}
@@ -255,84 +244,182 @@ private fun ListsContent(
}
/**
* 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].
* The home top bar. There is no title — the launcher icon already says which app
* this is. Settings is pinned at the right; the search action sits just left of it
* and stays put. Tapping search unfurls a pill to its left (the field grows out
* from the icon's side) while the search icon remains as the bar's fixed trailing
* icon, so neither action moves.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeSearchBar(
private fun HomeTopBar(
searchActive: Boolean,
query: String,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
onToggleSearch: () -> Unit,
onOpenSettings: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.surface) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(72.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Search occupies the flexible space left of settings. Pulled into its
// own composable so AnimatedVisibility resolves to the plain overload —
// a RowScope receiver in here would make that call ambiguous.
SearchSlot(
searchActive = searchActive,
query = query,
onQueryChange = onQueryChange,
onToggleSearch = onToggleSearch,
modifier = Modifier.weight(1f),
)
ShapedActionButton(
shape = ActionShapes.Settings,
icon = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(start = 8.dp),
size = 48.dp,
iconSize = 26.dp,
)
}
}
}
/**
* The search action and the field it unfurls. The magnifier is pinned to the end
* (right) of this slot and never moves; tapping it toggles search. When active a
* pill expands leftward from it ([expandHorizontally] anchored at the end) holding
* the query field, so the icon reads as the bar's fixed trailing icon.
*/
@Composable
private fun SearchSlot(
searchActive: Boolean,
query: String,
onQueryChange: (String) -> Unit,
onToggleSearch: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier, contentAlignment = Alignment.CenterEnd) {
AnimatedVisibility(
visible = searchActive,
enter = expandHorizontally(tween(300), expandFrom = Alignment.End) + fadeIn(tween(280)),
exit = shrinkHorizontally(tween(220), shrinkTowards = Alignment.End) + fadeOut(tween(140)),
) {
SearchPill(
query = query,
onQueryChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
)
}
// Always on top, at the end — the bar's fixed search icon and toggle.
ShapedActionButton(
shape = ActionShapes.Search,
icon = Icons.Rounded.Search,
contentDescription = stringResource(R.string.home_search_hint),
onClick = onToggleSearch,
size = 48.dp,
iconSize = 26.dp,
)
}
}
/**
* The expanding search input pill: the query field (auto-focused on open) and a
* clear button once there is text. Its trailing 48dp is left empty for the search
* icon that [SearchSlot] overlays at the end.
*/
@Composable
private fun SearchPill(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.height(52.dp),
) {
Row(
modifier = Modifier.padding(start = 18.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(),
modifier = Modifier.weight(1f).focusRequester(focusRequester),
decorationBox = { innerField ->
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
stringResource(R.string.home_search_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
innerField()
}
},
)
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
Icons.Rounded.Close,
contentDescription = stringResource(R.string.home_search_clear),
)
}
}
// Space reserved for the search icon SearchSlot overlays at the end.
Spacer(Modifier.width(48.dp))
}
}
}
/**
* Search results overlaying the home content: tasks whose title matches the query,
* across every list (completed included), open ones first. A blank query renders
* nothing so the home content shows through behind the open field.
*/
@Composable
private fun SearchResults(
query: String,
allTasks: List<Task>,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
if (query.isBlank()) return
val results = remember(query, allTasks) {
val q = query.trim()
if (q.isBlank()) emptyList()
else allTasks
allTasks
.filter { it.title.contains(q, ignoreCase = true) }
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
SearchBar(
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 = true,
onExpandedChange = { if (!it) onClose() },
placeholder = { Text(stringResource(R.string.home_search_hint)) },
leadingIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.home_search_close),
)
}
},
trailingIcon = {
if (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()) {
Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
if (results.isEmpty()) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.TopCenter) {
Text(
text = stringResource(R.string.home_search_empty, query.trim()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(results, key = { it.id }) { task ->
UpcomingRow(
task = task,
onClick = {
onClose()
onOpenTask(task.taskId)
},
)
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
}
}
}