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 package de.jeanlucmakiola.agendula.ui.lists
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState 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.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme 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.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
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
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.items
import androidx.compose.foundation.lazy.itemsIndexed 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.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
@@ -35,19 +44,14 @@ 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
import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator
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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -60,9 +64,10 @@ 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
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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 * 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. A search bar at the top expands * opens that task, and the FAB starts a new task. The search action in the top bar
* in place to filter every task by title. * slides open into a full-width field that filters every task by title.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ListsScreen( fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
@@ -102,7 +106,6 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
val closeSearch = { val closeSearch = {
@@ -113,34 +116,18 @@ fun ListsScreen(
BackHandler(enabled = searchActive, onBack = closeSearch) BackHandler(enabled = searchActive, onBack = closeSearch)
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier,
topBar = { topBar = {
MediumTopAppBar( HomeTopBar(
title = { Text(stringResource(R.string.app_name)) }, searchActive = searchActive,
actions = { query = query,
ShapedActionButton( onQueryChange = { query = it },
shape = ActionShapes.Search, onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
icon = Icons.Rounded.Search, onOpenSettings = onOpenSettings,
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,
) )
}, },
floatingActionButton = { floatingActionButton = {
// The FAB would otherwise float over the expanded search results. // The FAB would otherwise float over the search results.
if (!searchActive) { if (!searchActive) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewTask, onClick = onNewTask,
@@ -163,16 +150,18 @@ fun ListsScreen(
topPadding = 0.dp, topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp,
) )
// Only present while searching — it expands to cover the home // While searching with a non-blank query, results cover the home
// content with live results, and is absent otherwise. // content; an empty query leaves the home content visible behind
// the open field.
if (searchActive) { if (searchActive) {
HomeSearchBar( SearchResults(
query = query, query = query,
onQueryChange = { query = it },
onClose = closeSearch,
allTasks = s.allTasks, allTasks = s.allTasks,
onOpenTask = onOpenTask, onOpenTask = { taskId ->
modifier = Modifier.align(Alignment.TopCenter), 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 home top bar. There is no title — the launcher icon already says which app
* the top-bar search action). It opens expanded and focused, covering the home * this is. Settings is pinned at the right; the search action sits just left of it
* content with tasks whose title matches the query, across every list (completed * and stays put. Tapping search unfurls a pill to its left (the field grows out
* included). The back arrow or system back closes it via [onClose]. * 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 @Composable
private fun HomeSearchBar( private fun HomeTopBar(
searchActive: Boolean,
query: String, query: String,
onQueryChange: (String) -> Unit, 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>, allTasks: List<Task>,
onOpenTask: (Long) -> Unit, onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (query.isBlank()) return
val results = remember(query, allTasks) { val results = remember(query, allTasks) {
val q = query.trim() val q = query.trim()
if (q.isBlank()) emptyList() allTasks
else allTasks
.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 focusRequester = remember { FocusRequester() } Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
LaunchedEffect(Unit) { focusRequester.requestFocus() } if (results.isEmpty()) {
SearchBar( Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.TopCenter) {
modifier = modifier.fillMaxWidth(), Text(
expanded = true, text = stringResource(R.string.home_search_empty, query.trim()),
onExpandedChange = { if (!it) onClose() }, style = MaterialTheme.typography.bodyMedium,
// The enclosing app bar already consumes the status-bar inset; without this color = MaterialTheme.colorScheme.onSurfaceVariant,
// the search bar would add it again and float with a large top gap. )
windowInsets = WindowInsets(0), }
inputField = { } else {
SearchBarDefaults.InputField( LazyColumn(modifier = Modifier.fillMaxSize()) {
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()) {
items(results, key = { it.id }) { task -> items(results, key = { it.id }) { task ->
UpcomingRow( UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
task = task,
onClick = {
onClose()
onOpenTask(task.taskId)
},
)
} }
} }
} }