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:
@@ -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,25 +195,186 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user