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) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:47:02 +02:00
parent b266653e4e
commit 411e27659f
4 changed files with 274 additions and 21 deletions

View File

@@ -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,24 +195,185 @@ 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<SmartCount>, 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 ->
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 (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<SmartCount>, 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 (row.size == 1) Spacer(Modifier.weight(1f))
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<Task>,
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)
}
}

View File

@@ -29,9 +29,16 @@ sealed interface ListsUiState {
data class Content(
val smartCounts: List<SmartCount>,
val groups: List<AccountGroup>,
/** 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<Task>,
) : 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<TaskList>, openTasks: List<Task>): ListsUiState.Content {
private fun buildContent(
lists: List<TaskList>,
openTasks: List<Task>,
completedTasks: List<Task>,
): 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,
)
}
}

View File

@@ -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) },
)

View File

@@ -129,6 +129,17 @@
<string name="smart_all">All</string>
<string name="open_count">%1$d open</string>
<!-- Home: Today progress hero -->
<string name="home_today_progress">%1$d of %2$d done</string>
<string name="home_today_remaining">%1$d left</string>
<string name="home_today_all_done">All done 🎉</string>
<string name="home_today_empty">Nothing due today 🎉</string>
<!-- Home: Upcoming preview -->
<string name="home_upcoming_view_all">View all</string>
<string name="home_due_today">Today</string>
<string name="home_due_tomorrow">Tomorrow</string>
<!-- Reminders -->
<string name="reminder_due_at">Due %1$s</string>
<string name="reminder_channel_name">Task reminders</string>