From 411e27659f1f78d01134ed81d31dfcf79843579e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 22:47:02 +0200 Subject: [PATCH] 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