UI: fix subtask progress chip on the task list

Count a parent's subtask progress over the full task set in the
repository (attached to Task as subtaskTotal/subtaskDone) so completed
children are counted even on smart lists that hide closed tasks — the
chip showed "0 / 2" instead of "18 / 20". List membership (and the home
overview counts) are unchanged; only the parent's derived progress is.

Also wrap the task-row metadata in a FlowRow so the subtask chip drops
to a further line when a dated task with subtasks can't fit due + chips
on one line, rather than squeezing the trailing chip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 10:25:08 +02:00
parent 24cf8fe331
commit 3397e57794
3 changed files with 41 additions and 15 deletions

View File

@@ -2,7 +2,6 @@ package de.jeanlucmakiola.floret.data.tasks
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.floret.data.di.IoDispatcher
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.floret.domain.DayWindow
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.floret.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.floret.domain.TaskFilter
@@ -44,11 +43,28 @@ class TasksRepositoryImpl @Inject constructor(
private fun loadTasks(filter: TaskFilter): List<Task> { private fun loadTasks(filter: TaskFilter): List<Task> {
val query = when (filter) { val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true) is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
is TaskFilter.Smart -> TaskQuery(includeCompleted = filter.list == SmartList.COMPLETED) // Read completed tasks too, then let [TaskFiltering.matches] enforce each
// smart list's own open/closed semantics. We over-read on purpose so a
// parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
} }
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
return dataSource.tasks(query) val all = dataSource.tasks(query)
// (done, total) of direct children per parent, over the unfiltered set.
val progress: Map<Long, Pair<Int, Int>> = all
.filter { (it.parentId ?: 0L) > 0L }
.groupingBy { it.parentId!! }
.fold(0 to 0) { (done, total), t ->
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) } .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
} ?: task
}
.sortedWith(TaskSorting.DEFAULT) .sortedWith(TaskSorting.DEFAULT)
} }

View File

@@ -52,6 +52,13 @@ data class Task(
val distanceFromCurrent: Int?, val distanceFromCurrent: Int?,
val created: Instant?, val created: Instant?,
val lastModified: Instant?, val lastModified: Instant?,
/**
* Direct-subtask progress, filled in by the repository over the *full* task
* set so a parent's progress chip counts completed children even on smart
* lists that hide them. Both 0 when the task has no subtasks.
*/
val subtaskTotal: Int = 0,
val subtaskDone: Int = 0,
) { ) {
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED

View File

@@ -10,6 +10,8 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -147,12 +149,10 @@ private fun TaskListBody(
viewModel: TaskListViewModel, viewModel: TaskListViewModel,
) { ) {
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) } val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
// A parent represents its children via a progress chip, so drop children // A parent represents its children via a progress chip (counts come from the
// from the flat list — unless their parent isn't in this view (can happen in // repository, over the full set), so drop children from the flat list — unless
// smart lists), where we keep them standalone so nothing silently vanishes. // their parent isn't in this view (can happen in smart lists), where we keep
val childrenByParent = remember(state.tasks) { // them standalone so nothing silently vanishes.
state.tasks.filter { (it.parentId ?: 0L) > 0L }.groupBy { it.parentId!! }
}
val displayTasks = remember(state.tasks) { val displayTasks = remember(state.tasks) {
val present = state.tasks.mapTo(HashSet()) { it.taskId } val present = state.tasks.mapTo(HashSet()) { it.taskId }
state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present } state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
@@ -201,13 +201,12 @@ private fun TaskListBody(
} }
if (!collapsible || completedExpanded) { if (!collapsible || completedExpanded) {
itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task -> itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task ->
val children = childrenByParent[task.taskId]
TaskRow( TaskRow(
task = task, task = task,
position = positionOf(index, section.tasks.size), position = positionOf(index, section.tasks.size),
showListName = showListName, showListName = showListName,
subtaskDone = children?.count { it.isCompleted } ?: 0, subtaskDone = task.subtaskDone,
subtaskTotal = children?.size ?: 0, subtaskTotal = task.subtaskTotal,
onToggle = { viewModel.toggleComplete(task) }, onToggle = { viewModel.toggleComplete(task) },
onDelete = { viewModel.delete(task) }, onDelete = { viewModel.delete(task) },
onClick = { onOpenTask(task.taskId) }, onClick = { onOpenTask(task.taskId) },
@@ -300,7 +299,7 @@ private fun TaskRow(
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
private fun TaskRowContent( private fun TaskRowContent(
task: Task, task: Task,
@@ -361,9 +360,13 @@ private fun TaskRowContent(
) )
if (hasSupporting) { if (hasSupporting) {
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
Row( // Flow so the chips wrap to a further line when due date + list
verticalAlignment = Alignment.CenterVertically, // + subtask progress can't share one (e.g. a dated task with
// subtasks), rather than squeezing the trailing chip.
FlowRow(
verticalArrangement = Arrangement.spacedBy(3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) { ) {
if (due != null) { if (due != null) {
Text( Text(