From 411e27659f1f78d01134ed81d31dfcf79843579e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 22:47:02 +0200 Subject: [PATCH 1/7] home: Today progress ring + live Upcoming preview Rework the overview's 2x2 smart grid into a daily-momentum layout: - Promote Today into a full-width hero with an M3 Expressive CircularWavyProgressIndicator over "x of y done" for tasks due today. Empty/all-done states read as a calm finished state, not a bare 0. - Drop the white "Upcoming 0" tile (it shouted loudest while carrying the least) in favour of a live preview of the next few upcoming tasks, each a slim row with the quiet meta line and a tap-through to the task. - Overdue + All fall back to a 2-up of the existing tonal tiles. ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count the tasks already ticked off today (the open smart lists drop them). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agendula/ui/lists/ListsScreen.kt | 240 ++++++++++++++++-- .../agendula/ui/lists/ListsViewModel.kt | 43 +++- .../agendula/ui/navigation/AgendulaNavHost.kt | 1 + app/src/main/res/values/strings.xml | 11 + 4 files changed, 274 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 5ecbd3d..2939221 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.lists import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,18 +13,24 @@ 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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ListAlt import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Flag 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.MaterialTheme @@ -42,27 +49,37 @@ 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.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.Priority import de.jeanlucmakiola.agendula.domain.SmartList +import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton +import de.jeanlucmakiola.agendula.ui.common.priorityAccent +import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.time.formatDateTimeCompact +import java.time.LocalDate +import java.time.ZoneId /** - * Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live - * counts, then the user's lists grouped by account. Tapping a card or row opens - * that task list; the FAB starts a new task. + * 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. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ListsScreen( onOpenFilter: (TaskFilter) -> Unit, + onOpenTask: (Long) -> Unit, onNewTask: () -> Unit, onOpenSettings: () -> Unit, modifier: Modifier = Modifier, @@ -101,7 +118,7 @@ fun ListsScreen( when (val s = state) { ListsUiState.Loading -> Unit // brief; avoids a flash before first emission ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner) - is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter) + is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter, onOpenTask) } } } @@ -111,6 +128,7 @@ private fun ListsContent( state: ListsUiState.Content, inner: PaddingValues, onOpenFilter: (TaskFilter) -> Unit, + onOpenTask: (Long) -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -119,7 +137,34 @@ private fun ListsContent( bottom = inner.calculateBottomPadding() + 96.dp, ), ) { - item { SmartGrid(state.smartCounts, onOpenFilter) } + item { + TodayHero( + done = state.todayDone, + total = state.todayTotal, + onClick = { onOpenFilter(TaskFilter.Smart(SmartList.TODAY)) }, + ) + } + // Overdue + All sit below the Today hero as a quieter 2-up; Upcoming is no + // longer a count tile โ€” it becomes the live preview further down. + item { + SmartPairRow( + counts = state.smartCounts.filter { + it.smart == SmartList.OVERDUE || it.smart == SmartList.ALL + }, + onOpenFilter = onOpenFilter, + ) + } + + if (state.upcoming.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.smart_upcoming)) } + item { + UpcomingPreview( + tasks = state.upcoming, + onOpenTask = onOpenTask, + onViewAll = { onOpenFilter(TaskFilter.Smart(SmartList.UPCOMING)) }, + ) + } + } if (state.groups.isEmpty()) { item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) } @@ -150,27 +195,188 @@ private fun ListsContent( } } +/** + * The day's momentum: a wavy progress ring over "x of y done" for tasks due today. + * Tapping opens the Today list. When nothing is due today it drops the ring and + * reads as a calm, finished state rather than an empty 0. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun SmartGrid(counts: List, onOpenFilter: (TaskFilter) -> Unit) { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), +private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "todayCorner") + val left = total - done + Surface( + onClick = onClick, + shape = RoundedCornerShape(corner), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + interactionSource = interaction, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(140.dp), ) { - counts.chunked(2).forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - row.forEach { smart -> - SmartCard( - count = smart, - modifier = Modifier.weight(1f), - onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) }, + Row( + modifier = Modifier.fillMaxSize().padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Rounded.Today, contentDescription = null, modifier = Modifier.size(20.dp)) + Text(stringResource(R.string.smart_today), style = MaterialTheme.typography.titleMedium) + } + val headline = when { + total == 0 -> stringResource(R.string.home_today_empty) + left == 0 -> stringResource(R.string.home_today_all_done) + else -> stringResource(R.string.home_today_progress, done, total) + } + Text(headline, style = MaterialTheme.typography.headlineSmall) + if (total > 0 && left > 0) { + Text( + stringResource(R.string.home_today_remaining, left), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), ) } - if (row.size == 1) Spacer(Modifier.weight(1f)) + } + if (total > 0) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) { + CircularWavyProgressIndicator( + progress = { done.toFloat() / total }, + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.onPrimaryContainer, + trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f), + ) + Text("$done/$total", style = MaterialTheme.typography.titleMedium) + } } } } } +/** Overdue + All as a 2-up row of the existing tonal tiles. */ +@Composable +private fun SmartPairRow(counts: List, onOpenFilter: (TaskFilter) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + counts.forEach { smart -> + SmartCard( + count = smart, + modifier = Modifier.weight(1f), + onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) }, + ) + } + if (counts.size == 1) Spacer(Modifier.weight(1f)) + } +} + +/** A grouped card previewing the next few upcoming tasks, with a "view all" tail. */ +@Composable +private fun UpcomingPreview( + tasks: List, + onOpenTask: (Long) -> Unit, + onViewAll: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + Column { + tasks.forEach { task -> + UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) }) + } + Surface(onClick = onViewAll, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.home_upcoming_view_all), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.weight(1f)) + Icon( + Icons.Rounded.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +/** + * One slim upcoming row: list-colour avatar, title, then a quiet meta line of the + * relative due date and (if set) a tinted priority flag โ€” the same calm one-line + * treatment as the task list, minus the swipe machinery. + */ +@Composable +private fun UpcomingRow(task: Task, onClick: () -> Unit) { + val dark = isSystemInDarkTheme() + val muted = MaterialTheme.colorScheme.onSurfaceVariant + val metaStyle = MaterialTheme.typography.bodySmall + Surface(onClick = onClick, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ListColorChip(task.effectiveColor) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + task.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + upcomingDueLabel(task)?.let { Text(it, style = metaStyle, color = muted) } + if (task.priority != Priority.NONE) { + Text("ยท", style = metaStyle, color = muted) + Icon( + Icons.Rounded.Flag, + contentDescription = null, + tint = priorityAccent(task.priority, dark), + modifier = Modifier.size(13.dp), + ) + Text(priorityLabel(task.priority), style = metaStyle, color = muted) + } + } + } + } + } +} + +/** "Today" / "Tomorrow" for the near dates, else the compact date. */ +@Composable +private fun upcomingDueLabel(task: Task): String? { + val due = task.due ?: return null + val zone = remember { ZoneId.systemDefault() } + val today = remember { LocalDate.now(zone) } + val dueDate = remember(due) { + java.time.Instant.ofEpochMilli(due.toEpochMilliseconds()).atZone(zone).toLocalDate() + } + return when (dueDate) { + today -> stringResource(R.string.home_due_today) + today.plusDays(1) -> stringResource(R.string.home_due_tomorrow) + else -> due.formatDateTimeCompact(task.isAllDay) + } +} + private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color) @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 9fc2786..4b16502 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -29,9 +29,16 @@ sealed interface ListsUiState { data class Content( val smartCounts: List, val groups: List, + /** Completed vs. total tasks *due today* โ€” drives the progress ring. */ + val todayDone: Int, + val todayTotal: Int, + /** The next few open tasks (due tomorrow onward) for the inline preview. */ + val upcoming: List, ) : ListsUiState } +private const val UPCOMING_PREVIEW = 3 + /** The home overview: smart lists with live counts, then user lists by account. */ @HiltViewModel class ListsViewModel @Inject constructor( @@ -42,12 +49,19 @@ class ListsViewModel @Inject constructor( combine( repository.taskLists(), repository.tasks(TaskFilter.Smart(SmartList.ALL)), - ) { lists, openTasks -> - buildContent(lists, openTasks) as ListsUiState + // Open smart lists drop completed tasks, but the Today ring needs the + // ones already ticked off to show "x of y done", so read them too. + repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), + ) { lists, openTasks, completedTasks -> + buildContent(lists, openTasks, completedTasks) as ListsUiState }.catch { emit(ListsUiState.Failure) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) - private fun buildContent(lists: List, openTasks: List): ListsUiState.Content { + private fun buildContent( + lists: List, + openTasks: List, + completedTasks: List, + ): ListsUiState.Content { val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) // Count only top-level tasks: a subtask is represented by its parent (and // its progress chip), and an open subtask under a *completed* parent must @@ -62,6 +76,21 @@ class ListsViewModel @Inject constructor( SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)), SmartCount(SmartList.ALL, topLevel.size), ) + + // Today ring: completed vs. total tasks *due today*. The numerator is the + // top-level completed tasks whose due date falls in today's window; the + // denominator adds the still-open ones (the Today smart count above). + val openToday = count(SmartList.TODAY) + val completedDueToday = completedTasks.count { + !it.isSubtask && it.due != null && it.due >= todayStart && it.due < todayEnd + } + val todayTotal = openToday + completedDueToday + + // Upcoming preview: the next handful of open tasks due tomorrow onward, + // already sorted by the repository's default ordering. + val upcoming = topLevel + .filter { TaskFiltering.matches(it, TaskFilter.Smart(SmartList.UPCOMING), todayStart, todayEnd) } + .take(UPCOMING_PREVIEW) val openByList = topLevel.groupingBy { it.listId }.eachCount() val groups = lists .groupBy { it.accountName } @@ -72,6 +101,12 @@ class ListsViewModel @Inject constructor( ) } .sortedBy { it.accountName.lowercase() } - return ListsUiState.Content(smartCounts, groups) + return ListsUiState.Content( + smartCounts = smartCounts, + groups = groups, + todayDone = completedDueToday, + todayTotal = todayTotal, + upcoming = upcoming, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt index c43850e..72566c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt @@ -52,6 +52,7 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) { composable(Dest.LISTS) { ListsScreen( onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) }, + onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) }, onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) }, onOpenSettings = { nav.navigate(Dest.SETTINGS) }, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4bdc865..6624583 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -129,6 +129,17 @@ All %1$d open + + %1$d of %2$d done + %1$d left + All done ๐ŸŽ‰ + Nothing due today ๐ŸŽ‰ + + + View all + Today + Tomorrow + Due %1$s Task reminders From a8595e26b4dbe085d70c362f3ba8ae19615e550d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 23:07:12 +0200 Subject: [PATCH 2/7] home: inline expanding search over all tasks Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it is a "Search tasks" bar below the title row; tapping expands it in place to cover the home content with live results, filtering every task (open and completed) by title, case-insensitive. The leading icon flips to a back arrow while expanded, a clear button empties the query, and the FAB hides so it does not float over the results. Results reuse the upcoming preview row. ListsViewModel.Content now carries allTasks (open + completed) as the search corpus; filtering stays in memory so the provider query is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agendula/ui/lists/ListsScreen.kt | 156 ++++++++++++++++-- .../agendula/ui/lists/ListsViewModel.kt | 3 + app/src/main/res/values/strings.xml | 6 + 3 files changed, 152 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 2939221..7afee07 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -18,13 +18,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.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 +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.Flag +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Upcoming @@ -33,15 +37,21 @@ 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.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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -73,7 +83,8 @@ 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. + * 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. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,6 +98,8 @@ fun ListsScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + var query by rememberSaveable { mutableStateOf("") } + var searchExpanded by rememberSaveable { mutableStateOf(false) } Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -108,33 +121,63 @@ fun ListsScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onNewTask, - icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.new_task)) }, - ) + // The FAB would otherwise float over the expanded search results. + if (!searchExpanded) { + ExtendedFloatingActionButton( + onClick = onNewTask, + icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.new_task)) }, + ) + } }, ) { inner -> - when (val s = state) { - ListsUiState.Loading -> Unit // brief; avoids a flash before first emission - ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner) - is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter, onOpenTask) + // 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 + ListsUiState.Failure -> + CenteredMessage(stringResource(R.string.lists_failure), PaddingValues(0.dp)) + is ListsUiState.Content -> { + ListsContent( + state = s, + onOpenFilter = onOpenFilter, + onOpenTask = onOpenTask, + topPadding = SEARCH_BAR_CLEARANCE, + bottomPadding = inner.calculateBottomPadding() + 96.dp, + ) + HomeSearchBar( + query = query, + onQueryChange = { query = it }, + expanded = searchExpanded, + onExpandedChange = { searchExpanded = it }, + 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 private fun ListsContent( state: ListsUiState.Content, - inner: PaddingValues, onOpenFilter: (TaskFilter) -> Unit, onOpenTask: (Long) -> Unit, + topPadding: androidx.compose.ui.unit.Dp, + bottomPadding: androidx.compose.ui.unit.Dp, ) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( - top = inner.calculateTopPadding(), - bottom = inner.calculateBottomPadding() + 96.dp, + top = topPadding, + bottom = bottomPadding, ), ) { item { @@ -195,6 +238,93 @@ 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. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeSearchBar( + query: String, + onQueryChange: (String) -> Unit, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + allTasks: List, + onOpenTask: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val results = remember(query, allTasks) { + val q = query.trim() + if (q.isBlank()) emptyList() + else allTasks + .filter { it.title.contains(q, ignoreCase = true) } + .sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() })) + } + val collapse = { + onQueryChange("") + onExpandedChange(false) + } + SearchBar( + modifier = modifier.fillMaxWidth().padding(horizontal = if (expanded) 0.dp else 16.dp), + expanded = expanded, + onExpandedChange = onExpandedChange, + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = {}, + expanded = expanded, + onExpandedChange = onExpandedChange, + placeholder = { Text(stringResource(R.string.home_search_hint)) }, + leadingIcon = { + if (expanded) { + IconButton(onClick = collapse) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.home_search_close), + ) + } + } else { + Icon(Icons.Rounded.Search, contentDescription = null) + } + }, + trailingIcon = { + if (expanded && 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 -> + UpcomingRow( + task = task, + onClick = { + collapse() + onOpenTask(task.taskId) + }, + ) + } + } + } + } +} + /** * The day's momentum: a wavy progress ring over "x of y done" for tasks due today. * Tapping opens the Today list. When nothing is due today it drops the ring and diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 4b16502..a6d5071 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -34,6 +34,8 @@ sealed interface ListsUiState { val todayTotal: Int, /** The next few open tasks (due tomorrow onward) for the inline preview. */ val upcoming: List, + /** Every task (open and completed) โ€” the corpus the home search filters. */ + val allTasks: List, ) : ListsUiState } @@ -107,6 +109,7 @@ class ListsViewModel @Inject constructor( todayDone = completedDueToday, todayTotal = todayTotal, upcoming = upcoming, + allTasks = openTasks + completedTasks, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6624583..c9f7155 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,12 @@ Today Tomorrow + + Search tasks + Clear search + Close search + No tasks match โ€œ%1$sโ€ + Due %1$s Task reminders From c53511196d2270a9b72b3aefb385e7e5f907e304 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 23:07:25 +0200 Subject: [PATCH 3/7] =?UTF-8?q?icon:=20real=20launcher=20mark=20=E2=80=94?= =?UTF-8?q?=20task=20card,=20check,=20calendula=20bloom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder check-mark foreground with the finished agendula mark (converted from design/icon/agendula_icon.svg): a rounded line-art task card with a check, plus a small Calendula bloom badge in the open bottom-right corner โ€” the sibling of Calendula's calendar mark. Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate), so the two apps read as a family while staying distinct. Scaled 0.66 to match Calendula's footprint and ~2.8dp stroke weight; reused as the slot for themed icons. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../res/drawable/ic_launcher_foreground.xml | 91 ++++++++++++++++--- design/icon/agendula_icon.svg | 11 +++ 2 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 design/icon/agendula_icon.svg diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 676d561..450b032 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,22 +1,85 @@ - + android:viewportWidth="512" + android:viewportHeight="512"> + + + + + + + + + + + + + + diff --git a/design/icon/agendula_icon.svg b/design/icon/agendula_icon.svg new file mode 100644 index 0000000..02b5d0d --- /dev/null +++ b/design/icon/agendula_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + From e6f503c02a6fa57bc4ac3a8039f85bf9d829e0e7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 23:34:54 +0200 Subject: [PATCH 4/7] 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) --- .../agendula/ui/common/ShapedActionButton.kt | 3 + .../agendula/ui/lists/ListsScreen.kt | 102 ++++++++++-------- 2 files changed, 61 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt index d80de66..a434e43 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 7afee07..842e0ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -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,27 +160,27 @@ fun ListsScreen( state = s, onOpenFilter = onOpenFilter, onOpenTask = onOpenTask, - topPadding = SEARCH_BAR_CLEARANCE, + topPadding = 0.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp, ) - HomeSearchBar( - query = query, - onQueryChange = { query = it }, - expanded = searchExpanded, - onExpandedChange = { searchExpanded = it }, - allTasks = s.allTasks, - onOpenTask = onOpenTask, - modifier = Modifier.align(Alignment.TopCenter), - ) + // 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 }, + onClose = closeSearch, + 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 private fun ListsContent( 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 - * 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, 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) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.home_search_close), - ) - } - } else { - Icon(Icons.Rounded.Search, contentDescription = null) + IconButton(onClick = onClose) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.home_search_close), + ) } }, 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) }, ) From 2e50356f81838ddfc5655f9ba80ebeb24b4c8a28 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 23:35:03 +0200 Subject: [PATCH 5/7] icon: centre the launcher mark on the canvas The first pass shifted the mark off-centre. Scale 0.66 about the canvas centre, and centre the task CARD (not the card+bloom bounding box, which the overhanging bloom badge drags low): pivot the Y-scale at the card's centre (y=242.76) and translate +13.24 so the card sits dead-centre, horizontally and vertically, with the bloom badging out to the lower-right. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../res/drawable/ic_launcher_foreground.xml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 450b032..e809cc4 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -12,9 +12,14 @@ slot so Android 13+ themed-icon launchers can recolour it. Centering / scale: - - The artwork's visual centre sits at (269, 256) in the 512 viewport. - - Pivot there, scale 0.66 (matching Calendula's ~2.8dp stroke weight and - generous padding), then translate (-13, 0) onto the 512 canvas centre. + - Scale 0.66 about the canvas centre (matching Calendula's ~2.8dp stroke + weight and generous padding). + - The eye centres on the task CARD, not the card+bloom bounding box (the + bloom is a small badge that overhangs the bottom-right). So vertically we + centre the card itself: its geometric centre is y=242.76, so we pivot the + Y-scale there and translate +13.24 to drop that centre onto the canvas + centre (256). Horizontally the card already sits centred, so X scales + about 256 untouched. --> + android:translateY="13.24"> Date: Mon, 29 Jun 2026 00:00:42 +0200 Subject: [PATCH 6/7] 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) --- .../agendula/ui/lists/ListsScreen.kt | 301 +++++++++++------- 1 file changed, 194 insertions(+), 107 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 842e0ea..778c149 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -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, 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) }) } } } From 245f1db536877bcc3d524fe9746387cd3f27bbee Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 29 Jun 2026 00:01:07 +0200 Subject: [PATCH 7/7] ui: move the action press flourish onto the shape, spin the gear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The press animation turned the glyph; put it on the scalloped cookie container instead and make it stronger (scale 1โ†’0.82, rotate 0โ†’40ยฐ). The glyph holds upright via a counter-rotation, so the shape spins while a magnifier or list icon stays readable. New spinIcon opts a glyph into its own quarter turn โ€” the settings gear uses it, so it reads as a gear cranking. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agendula/ui/common/ShapedActionButton.kt | 27 ++++++++++++++----- .../agendula/ui/lists/ListsScreen.kt | 1 + 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt index a434e43..f7ddf99 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt @@ -31,8 +31,10 @@ import androidx.graphics.shapes.RoundedPolygon * its own [shape] and [containerColor], so a row of them reads as a set of * distinct little tokens rather than identical grey glyphs. * - * Pressing springs the icon down a touch and gives it a small turn โ€” a light - * expressive flourish, no library motion APIs needed. + * Pressing springs the whole shape down and gives it a turn โ€” the scalloped + * container is what dips and spins, while the glyph inside stays upright (a + * spinning magnifier or list icon would just read as wrong). [spinIcon] opts a + * glyph into turning too, for icons that read well mid-spin like the gear. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -46,15 +48,25 @@ fun ShapedActionButton( contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer, size: Dp = 40.dp, iconSize: Dp = 22.dp, + spinIcon: Boolean = false, ) { val interaction = remember { MutableInteractionSource() } val pressed by interaction.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "actionScale") - val rotation by animateFloatAsState(if (pressed) 24f else 0f, label = "actionRotation") + // The shape (the scalloped cookie) turns and dips on press. + val shapeRotation by animateFloatAsState(if (pressed) 40f else 0f, label = "shapeRotation") + val scale by animateFloatAsState(if (pressed) 0.82f else 1f, label = "shapeScale") + // The glyph's *net* turn: 0 keeps it upright, spinIcon gives it a quarter turn. + val iconRotation by animateFloatAsState( + if (pressed && spinIcon) 90f else 0f, + label = "iconRotation", + ) Surface( onClick = onClick, - modifier = modifier.size(size), + modifier = modifier + .size(size) + .scale(scale) + .rotate(shapeRotation), shape = shape.toShape(), color = containerColor, contentColor = contentColor, @@ -64,10 +76,11 @@ fun ShapedActionButton( Icon( imageVector = icon, contentDescription = contentDescription, + // Counter the container's turn so the glyph's net rotation is just + // [iconRotation] โ€” upright by default, a quarter turn for the gear. modifier = Modifier .size(iconSize) - .scale(scale) - .rotate(rotation), + .rotate(iconRotation - shapeRotation), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 778c149..90bdff0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -285,6 +285,7 @@ private fun HomeTopBar( modifier = Modifier.padding(start = 8.dp), size = 48.dp, iconSize = 26.dp, + spinIcon = true, ) } }