M4: percent-complete, conflict-safe saves & subtask reparent
All checks were successful
CI / ci (push) Successful in 6m49s

M3 detail/edit polish:
- "Progress" slider on the edit form writes Tasks.PERCENT_COMPLETE (clamped
  0-100, 5% detents); status stays owned by the complete toggle.
- Conflict-safe saves: updateTask re-checks the provider's last_modified against
  the value captured when the form loaded and throws TaskConflictException; the
  editor offers overwrite-or-cancel instead of clobbering an external change.

M4 subtasks (UI):
- Reparent: a "Parent task" picker files a task under any top-level task in its
  list (or "None" to promote it); candidates stay top-level to keep nesting one
  level deep. Switching list clears the now-invalid parent.
- Tapping a subtask opens its own detail (a new TaskDetail entry, own VM).

Tests: percent clamping, parent-id write, populatedFields reveal logic.
Docs: ROADMAP M2/M3/M4 reconciled to the codebase; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 14:28:48 +02:00
parent 3db553da85
commit e0c56a73e2
19 changed files with 1145 additions and 107 deletions

View File

@@ -6,6 +6,31 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
## [Unreleased]
### Added
- M3 detail/edit polish: a "Progress" slider (percent-complete, 5% detents,
written to `Tasks.PERCENT_COMPLETE`) and conflict-safe saves — `updateTask`
re-checks the provider's `last_modified` against the value captured when the
form loaded and surfaces an overwrite-or-cancel prompt instead of clobbering an
external change (e.g. a DAVx5 sync).
- M4 subtasks (UI): reparent — a full-width, searchable "Parent task" sheet on
the edit form, with candidates grouped by due-date section, files a task under
any active top-level task in its list (or "None" to promote it); switching list
clears the now-invalid parent. Tapping a subtask in the detail screen opens its
own detail, which shows a "Part of …" parent card. On the task list a parent has
a dedicated expand button that reveals its children as a nested grouped run,
ending with an inline "add a subtask" row (an opt-out toggle is planned for the
M6 settings screen).
- Priority is coloured by level (green / amber / red pastels) on the list, detail,
and edit screens.
### Fixed
- Overview open-counts now count top-level tasks only, so subtasks — and
especially open subtasks under a completed parent — no longer inflate a list's
"N open" or the smart-list counts.
- Swipe-to-delete now reveals its red background + icon as you drag (tracking the
live direction, not just the settled target), and a floating "Deleted · Undo"
chip defers the actual delete so it can be restored.
## [0.1.0] - 2026-06-18
### Added

View File

@@ -7,3 +7,10 @@ class ProviderUnavailableException :
/** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) :
RuntimeException("Task write failed: $operation")
/**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
* it, so saving would clobber that change. The UI offers to overwrite or reload.
*/
class TaskConflictException(taskId: Long) :
RuntimeException("Task $taskId changed since it was loaded")

View File

@@ -17,6 +17,27 @@ object TaskWriteMapper {
put(Tasks.LIST_ID, form.listId)
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
put(Tasks.PRIORITY, form.priority.toICal())
// Keep completion in sync with progress whenever the form carries a
// percent (the Progress field was used). The provider already auto-
// completes at 100% but won't reopen below it — left to itself it strands
// a task "done at 75%" — so we set status both ways. COMPLETED is only
// cleared on reopen; at 100% we leave it for the provider to fill/keep so
// re-saving a finished task doesn't churn its completion timestamp. When
// no percent is present the standalone complete toggle stays authoritative.
val percent = form.percentComplete?.coerceIn(0, 100)
put(Tasks.PERCENT_COMPLETE, percent)
when {
percent == null -> Unit
percent >= 100 -> put(Tasks.STATUS, TasksContract.STATUS_COMPLETED)
percent > 0 -> {
put(Tasks.STATUS, TasksContract.STATUS_IN_PROCESS)
put(Tasks.COMPLETED, null)
}
else -> {
put(Tasks.STATUS, TasksContract.STATUS_NEEDS_ACTION)
put(Tasks.COMPLETED, null)
}
}
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.toEpochMilliseconds())

View File

@@ -6,6 +6,7 @@ import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/** Whether Floret can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
@@ -21,7 +22,14 @@ interface TasksRepository {
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm)
/**
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
* task's current `last_modified` is re-checked first and a
* [TaskConflictException] is thrown if it differs — i.e. something changed it
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
*/
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long

View File

@@ -22,6 +22,7 @@ import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Instant
@Singleton
class TasksRepositoryImpl @Inject constructor(
@@ -36,7 +37,11 @@ class TasksRepositoryImpl @Inject constructor(
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task ->
TaskDetail(task, dataSource.subtasks(taskId).filter { it.taskId != taskId })
TaskDetail(
task = task,
subtasks = dataSource.subtasks(taskId).filter { it.taskId != taskId },
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
)
}
}
@@ -71,8 +76,17 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun createTask(form: TaskForm): Long =
withContext(io) { dataSource.insertTask(form) }
override suspend fun updateTask(taskId: Long, form: TaskForm) =
withContext(io) { dataSource.updateTask(taskId, form) }
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) {
// Conflict-safe overwrite: re-read just before writing and bail if the
// provider's last_modified moved since the form captured it (external
// sync / another app). A null baseline means "force / overwrite anyway".
if (expectedLastModified != null) {
val current = dataSource.task(taskId)?.lastModified
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
}
dataSource.updateTask(taskId, form)
}
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
withContext(io) { dataSource.setCompleted(taskId, completed) }

View File

@@ -67,10 +67,11 @@ data class Task(
val effectiveColor: Int get() = taskColor ?: listColor
}
/** Detail bundle: a task plus its direct children. */
/** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
data class TaskDetail(
val task: Task,
val subtasks: List<Task>,
val parent: Task? = null,
)
// --- pure provider <-> domain value mapping (unit-testable) ------------------

View File

@@ -16,6 +16,8 @@ data class TaskForm(
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
/** 0..100 progress; `null` leaves it unset (cleared). */
val percentComplete: Int? = null,
/** Minutes before [due] to fire a reminder; `null` = no reminder. */
val reminderMinutesBeforeDue: Int? = null,
) {
@@ -37,11 +39,13 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
* disclosure generic, so adding a field later is one entry plus its card.
*/
enum class TaskFormField { Description, Priority, Reminder }
enum class TaskFormField { Description, Priority, Progress, Parent, Reminder }
/** The optional fields that already carry a value — auto-revealed when editing. */
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
if (!description.isNullOrBlank()) add(TaskFormField.Description)
if (priority != Priority.NONE) add(TaskFormField.Priority)
if ((percentComplete ?: 0) > 0) add(TaskFormField.Progress)
if (parentId != null && parentId > 0) add(TaskFormField.Parent)
if (reminderMinutesBeforeDue != null) add(TaskFormField.Reminder)
}

View File

@@ -0,0 +1,109 @@
package de.jeanlucmakiola.floret.ui.common
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.domain.Priority
/**
* Priority has no semantic Material role (M3 only ships `error` for danger), so
* we give the ranked levels their own hues — green / amber / red. They're softened
* harder than the list-colour [pastelize] (lower saturation, higher value) so the
* chips stay gentle and low-key rather than shouting. [Priority.NONE] has no
* colour (callers render it neutral).
*/
fun priorityFill(priority: Priority, dark: Boolean): Color {
val base = when (priority) {
Priority.HIGH -> 0xFFE53935.toInt() // red
Priority.MEDIUM -> 0xFFFFB300.toInt() // amber
Priority.LOW -> 0xFF43A047.toInt() // green
Priority.NONE -> return Color.Unspecified
}
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(base, hsv)
hsv[1] = if (dark) 0.36f else 0.22f // desaturate toward pastel
hsv[2] = if (dark) 0.58f else 0.92f // light, soft fill in light; muted in dark
return Color(android.graphics.Color.HSVToColor(hsv))
}
/**
* A coloured status pill for a task's priority: a flag plus the level's [label],
* filled in that level's pastel hue. Used read-only in the list and detail
* screens (default [selected] = true), and as a selectable option in the edit
* form — there [selected] = false shows the hue on the flag with an outlined,
* unfilled container, and tapping commits the choice via [onClick].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PriorityChip(
priority: Priority,
label: String,
modifier: Modifier = Modifier,
selected: Boolean = true,
showIcon: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val dark = isSystemInDarkTheme()
val fill = priorityFill(priority, dark)
val hasColor = fill.isSpecified
val container: Color
val content: Color
val iconTint: Color
var border: BorderStroke? = null
when {
selected && hasColor -> {
container = fill
content = if (fill.luminance() > 0.5f) Color.Black else Color.White
iconTint = content
}
selected -> { // NONE, selected — neutral fill, no hue to show
container = MaterialTheme.colorScheme.surfaceContainerHighest
content = MaterialTheme.colorScheme.onSurfaceVariant
iconTint = content
}
else -> { // an unselected option in a picker — outline, hue kept on the flag
container = Color.Transparent
content = MaterialTheme.colorScheme.onSurfaceVariant
iconTint = if (hasColor) fill else MaterialTheme.colorScheme.onSurfaceVariant
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
}
}
val body: @Composable () -> Unit = {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showIcon) {
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp))
}
Text(label, style = MaterialTheme.typography.labelMedium, color = content)
}
}
val shape = RoundedCornerShape(50)
if (onClick != null) {
Surface(onClick = onClick, color = container, shape = shape, border = border, modifier = modifier) { body() }
} else {
Surface(color = container, shape = shape, border = border, modifier = modifier) { body() }
}
}

View File

@@ -27,6 +27,7 @@ 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.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
@@ -70,12 +71,12 @@ import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail
import de.jeanlucmakiola.floret.ui.common.GroupedSurface
import de.jeanlucmakiola.floret.ui.common.PriorityChip
import de.jeanlucmakiola.floret.ui.common.formatDate
import de.jeanlucmakiola.floret.ui.common.formatTime
import de.jeanlucmakiola.floret.ui.common.pastelize
import de.jeanlucmakiola.floret.ui.common.positionOf
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.ui.tasklist.priorityTint
/**
* One task's detail. Each fact is its own tonal card with a leading icon (the
@@ -88,6 +89,7 @@ fun TaskDetailScreen(
onEdit: () -> Unit,
onDeleted: () -> Unit,
onBack: () -> Unit,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskDetailViewModel = hiltViewModel(),
) {
@@ -136,7 +138,7 @@ fun TaskDetailScreen(
onToggle = { viewModel.toggleComplete(s.detail.task) },
onToggleSubtask = { viewModel.toggleComplete(it) },
onAddSubtask = { viewModel.addSubtask(it) },
onOpenSubtask = { /* reserved: subtask navigation lands with the subtasks milestone */ },
onOpenTask = onOpenTask,
)
}
}
@@ -149,7 +151,7 @@ private fun DetailBody(
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
onOpenSubtask: (Long) -> Unit,
onOpenTask: (Long) -> Unit,
) {
val task = detail.task
val dark = isSystemInDarkTheme()
@@ -190,6 +192,28 @@ private fun DetailBody(
// One card per fact; gaps between, no shared container (they aren't a group).
val cards = buildList<@Composable () -> Unit> {
// Parent — only when this is a subtask. Conveys that it's filed under
// another task (and so doesn't stand alone in the list); taps open it.
detail.parent?.let { parent ->
add {
DetailCard(
icon = Icons.Rounded.AccountTree,
iconContentDescription = stringResource(R.string.detail_parent),
onClick = { onOpenTask(parent.taskId) },
) {
Text(
text = stringResource(R.string.detail_part_of),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = parent.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.titleMedium,
)
}
}
}
// When — the Calendula time card: date primary, time(s) secondary.
taskWhenLines(task)?.let { (primary, secondary) ->
add {
@@ -221,25 +245,28 @@ private fun DetailBody(
}
}
// Priority — the flag moves to the front, tinted like the list rows.
// Priority — a coloured status chip in the level's hue.
if (task.priority != de.jeanlucmakiola.floret.domain.Priority.NONE) {
add {
DetailCard(
icon = Icons.Rounded.Flag,
iconTint = priorityTint(task.priority),
iconContentDescription = stringResource(R.string.detail_priority),
) {
Text(priorityLabel(task.priority), style = MaterialTheme.typography.titleMedium)
PriorityChip(
priority = task.priority,
label = priorityLabel(task.priority),
showIcon = false,
)
}
}
}
// Progress — only meaningful with subtasks. Uses the provider's
// percent when set, otherwise the subtask completion ratio.
if (detail.subtasks.isNotEmpty()) {
val total = detail.subtasks.size
val doneCount = detail.subtasks.count { it.isCompleted }
val pct = task.percentComplete ?: (doneCount * 100 / total)
// Progress — shown when the task carries its own percent or has
// subtasks (then the completion ratio fills in if no percent is set).
val hasSubtasks = detail.subtasks.isNotEmpty()
if ((task.percentComplete ?: 0) > 0 || hasSubtasks) {
val pct = task.percentComplete
?: (detail.subtasks.count { it.isCompleted } * 100 / detail.subtasks.size)
add {
DetailCard(icon = Icons.Rounded.Percent, iconContentDescription = stringResource(R.string.detail_progress)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@@ -275,7 +302,7 @@ private fun DetailBody(
onToggle = { subtasksExpanded = !subtasksExpanded },
onToggleSubtask = onToggleSubtask,
onAddSubtask = onAddSubtask,
onOpenSubtask = onOpenSubtask,
onOpenSubtask = onOpenTask,
)
}
}
@@ -436,13 +463,12 @@ private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
val shape = RoundedCornerShape(16.dp)
val color = MaterialTheme.colorScheme.surfaceContainerHigh
val body: @Composable () -> Unit = {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -457,6 +483,11 @@ private fun DetailCard(
Column(modifier = Modifier.weight(1f), content = content)
}
}
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
} else {
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
}
}
/**

View File

@@ -20,11 +20,16 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.material.icons.rounded.Checklist
@@ -33,7 +38,9 @@ import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DatePicker
@@ -42,10 +49,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -54,17 +64,21 @@ import androidx.compose.material3.TimePicker
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTimePickerState
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.rememberCoroutineScope
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
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -72,21 +86,31 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.domain.DayWindow
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFormError
import de.jeanlucmakiola.floret.domain.TaskFormField
import de.jeanlucmakiola.floret.domain.TaskList
import de.jeanlucmakiola.floret.domain.TaskSection
import de.jeanlucmakiola.floret.domain.TaskSections
import de.jeanlucmakiola.floret.ui.common.GroupedRow
import de.jeanlucmakiola.floret.ui.common.InlineTextField
import de.jeanlucmakiola.floret.ui.common.OptionCard
import de.jeanlucmakiola.floret.ui.common.priorityFill
import de.jeanlucmakiola.floret.ui.common.formatDate
import de.jeanlucmakiola.floret.ui.common.formatTime
import de.jeanlucmakiola.floret.ui.common.localToInstant
import de.jeanlucmakiola.floret.ui.common.pastelize
import de.jeanlucmakiola.floret.ui.common.positionOf
import de.jeanlucmakiola.floret.ui.common.toLocalDate
import de.jeanlucmakiola.floret.ui.common.toLocalTime
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlinx.coroutines.launch
import kotlin.time.Clock
import kotlin.time.Instant
/**
@@ -160,6 +184,7 @@ private fun EditContent(
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) }
var showParentPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
@@ -297,14 +322,26 @@ private fun EditContent(
)
}
Spacer(Modifier.height(12.dp))
// The M3 single-select segmented row, but the active segment
// takes its priority hue so the choice reads in colour.
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
Priority.entries.forEachIndexed { index, priority ->
val fill = priorityFill(priority, dark)
val active = fill.isSpecified
SegmentedButton(
selected = state.priority == priority,
onClick = { viewModel.onPriorityChange(priority) },
shape = SegmentedButtonDefaults.itemShape(index, Priority.entries.size),
// Drop the selected-check icon; with four buttons
// its reserved width is what crowds the labels.
colors = SegmentedButtonDefaults.colors(
activeContainerColor = if (active) fill else MaterialTheme.colorScheme.secondaryContainer,
activeContentColor = when {
!active -> MaterialTheme.colorScheme.onSecondaryContainer
fill.luminance() > 0.5f -> Color.Black
else -> Color.White
},
),
// Drop the selected-check icon; with four buttons its
// reserved width is what crowds the labels.
icon = {},
) {
Text(
@@ -319,6 +356,76 @@ private fun EditContent(
}
}
OptionalFormSection(visible = TaskFormField.Progress in state.visibleFields) {
Spacer(Modifier.height(gap))
val pct = state.percentComplete ?: 0
// Full-width like Priority: the slider needs the card's whole width,
// so the label/readout sit on a header row above it.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Rounded.Percent,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.edit_progress_label),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.percent_complete, pct),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
Slider(
value = pct.toFloat(),
onValueChange = { viewModel.onPercentChange(it.toInt()) },
valueRange = 0f..100f,
steps = 19, // 5% detents
)
}
}
}
OptionalFormSection(visible = TaskFormField.Parent in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
icon = Icons.Rounded.AccountTree,
iconContentDescription = stringResource(R.string.edit_parent_label),
onClick = { showParentPicker = true },
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.parent?.title?.ifBlank { stringResource(R.string.task_untitled) }
?: stringResource(R.string.edit_parent_none),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.edit_parent_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.Rounded.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
OptionalFormSection(visible = TaskFormField.Reminder in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
@@ -391,6 +498,31 @@ private fun EditContent(
)
}
if (showParentPicker) {
ParentPickerSheet(
candidates = state.parentCandidates,
selectedId = state.parentId,
onSelect = { viewModel.onParentChange(it); showParentPicker = false },
onDismiss = { showParentPicker = false },
)
}
if (state.saveConflict) {
AlertDialog(
onDismissRequest = viewModel::dismissConflict,
title = { Text(stringResource(R.string.edit_conflict_title)) },
text = { Text(stringResource(R.string.edit_conflict_body)) },
confirmButton = {
TextButton(onClick = { viewModel.save(force = true) }) {
Text(stringResource(R.string.edit_conflict_overwrite))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissConflict) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
if (showReminderPicker) {
ReminderPickerDialog(
selected = state.reminderMinutesBeforeDue,
@@ -650,6 +782,139 @@ private fun ListPickerDialog(
)
}
/**
* Pick the task to file this one under, or "None" to keep it top-level. A
* full-width modal sheet (not a cramped dialog): a search field filters by title,
* and candidates group under the same due-date sections as the task list
* (Overdue / Today / Upcoming / No date) so the right one is easy to find in a
* long list. Only active tasks are offered (the VM filters closed ones out).
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun ParentPickerSheet(
candidates: List<Task>,
selectedId: Long?,
onSelect: (Long?) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
// Animate the sheet out before reporting the pick, so it doesn't snap shut.
fun choose(id: Long?) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) onSelect(id)
}
}
var query by rememberSaveable { mutableStateOf("") }
val filtered = remember(candidates, query) {
val q = query.trim()
if (q.isEmpty()) candidates else candidates.filter { it.title.contains(q, ignoreCase = true) }
}
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
val sections = remember(filtered) { TaskSections.of(filtered, todayStart, todayEnd) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 32.dp),
) {
item(key = "title") {
Text(
text = stringResource(R.string.edit_parent_label),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 12.dp),
)
}
// The search field pins to the top of the sheet while the list scrolls.
stickyHeader(key = "search") {
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { query = "" }) {
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
}
}
},
placeholder = { Text(stringResource(R.string.edit_parent_search)) },
shape = RoundedCornerShape(28.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 12.dp),
)
}
}
// "None" — promote to top-level. Pinned above the grouped candidates.
item(key = "none") {
GroupedRow(
title = stringResource(R.string.edit_parent_none),
position = de.jeanlucmakiola.floret.ui.common.Position.Alone,
selected = selectedId == null,
minHeight = 56.dp,
onClick = { choose(null) },
modifier = Modifier.padding(bottom = 4.dp),
)
}
if (sections.isEmpty()) {
item(key = "empty") {
Text(
text = stringResource(R.string.edit_parent_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
)
}
}
sections.forEach { section ->
stickyHeader(key = "hdr-${section.section}") {
SheetSectionHeader(parentSectionLabel(section.section))
}
itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task ->
GroupedRow(
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
position = positionOf(index, section.tasks.size),
summary = task.due?.formatDate(),
selected = task.taskId == selectedId,
minHeight = 56.dp,
onClick = { choose(task.taskId) },
)
}
}
}
}
}
/** Section header inside the parent sheet — opaque so rows scroll cleanly under it. */
@Composable
private fun SheetSectionHeader(text: String) {
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 6.dp),
)
}
}
@Composable
private fun parentSectionLabel(section: TaskSection): String = stringResource(
when (section) {
TaskSection.OVERDUE -> R.string.section_overdue
TaskSection.TODAY -> R.string.section_today
TaskSection.UPCOMING -> R.string.section_upcoming
TaskSection.NO_DATE -> R.string.section_no_date
TaskSection.COMPLETED -> R.string.section_completed
},
)
@Composable
private fun ReminderPickerDialog(
selected: Int?,
@@ -706,12 +971,16 @@ private fun FieldPickerDialog(
private fun fieldLabel(field: TaskFormField): Int = when (field) {
TaskFormField.Description -> R.string.field_description
TaskFormField.Priority -> R.string.edit_priority_label
TaskFormField.Progress -> R.string.edit_progress_label
TaskFormField.Parent -> R.string.edit_parent_label
TaskFormField.Reminder -> R.string.edit_reminder_label
}
private fun fieldIcon(field: TaskFormField): ImageVector = when (field) {
TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes
TaskFormField.Priority -> Icons.Rounded.Flag
TaskFormField.Progress -> Icons.Rounded.Percent
TaskFormField.Parent -> Icons.Rounded.AccountTree
TaskFormField.Reminder -> Icons.Rounded.Notifications
}

View File

@@ -5,8 +5,11 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
import de.jeanlucmakiola.floret.data.tasks.TaskConflictException
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskFormError
import de.jeanlucmakiola.floret.domain.TaskFormField
@@ -36,16 +39,24 @@ data class TaskEditUiState(
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
val percentComplete: Int? = null,
val reminderMinutesBeforeDue: Int? = null,
val lists: List<TaskList> = emptyList(),
/** Top-level tasks in the current list this task can be filed under (reparent). */
val parentCandidates: List<Task> = emptyList(),
/** Optional sections currently shown (core Title / List / When are always on). */
val visibleFields: Set<TaskFormField> = emptySet(),
/** Optional sections still tucked behind "More fields". */
val hiddenFields: List<TaskFormField> = emptyList(),
val errors: Set<TaskFormError> = emptySet(),
val saveFailed: Boolean = false,
/** The task changed underneath us; the screen asks to overwrite or cancel. */
val saveConflict: Boolean = false,
val saved: Boolean = false,
)
) {
/** The currently-selected parent task, if this task is filed under one. */
val parent: Task? get() = parentCandidates.firstOrNull { it.taskId == parentId }
}
@HiltViewModel
class TaskEditViewModel @Inject constructor(
@@ -59,12 +70,16 @@ class TaskEditViewModel @Inject constructor(
private var editingTaskId: Long? = null
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
private var baselineLastModified: Instant? = null
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
private var defaultFields: Set<TaskFormField> = emptySet()
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
editingTaskId = null
baselineLastModified = null
viewModelScope.launch {
val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields
@@ -80,6 +95,7 @@ class TaskEditViewModel @Inject constructor(
listId = defaultList,
parentId = parentId,
lists = lists,
parentCandidates = loadParents(defaultList, selfId = null),
),
)
}
@@ -96,6 +112,7 @@ class TaskEditViewModel @Inject constructor(
_state.value = _state.value.copy(loading = false, lists = lists)
return@launch
}
baselineLastModified = task.lastModified
_state.value = withFields(
TaskEditUiState(
loading = false,
@@ -108,12 +125,26 @@ class TaskEditViewModel @Inject constructor(
isAllDay = task.isAllDay,
priority = task.priority,
parentId = task.parentId,
percentComplete = task.percentComplete,
lists = lists,
parentCandidates = loadParents(task.listId, selfId = taskId),
),
)
}
}
/**
* Tasks the edited task can be filed under: active, top-level tasks in
* [listId], minus the task itself (a task can't parent itself). Restricting to
* top-level keeps the hierarchy one level deep (matching the detail screen),
* and to active so you don't file work under something already done/cancelled.
*/
private suspend fun loadParents(listId: Long?, selfId: Long?): List<Task> {
if (listId == null) return emptyList()
val tasks = runCatching { repository.tasks(TaskFilter.OfList(listId)).first() }.getOrElse { emptyList() }
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
}
/** Reveal an optional section (one tap from the "More fields" picker). */
fun revealField(field: TaskFormField) = update {
val visible = it.visibleFields + field
@@ -134,14 +165,30 @@ class TaskEditViewModel @Inject constructor(
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
fun onDescriptionChange(value: String) = update { it.copy(description = value) }
fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) }
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
fun onListChange(listId: Long) {
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
viewModelScope.launch {
val candidates = loadParents(listId, selfId = editingTaskId)
update { it.copy(parentCandidates = candidates) }
}
}
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
fun save() {
/** Dismiss the conflict prompt without saving (the user keeps editing). */
fun dismissConflict() = update { it.copy(saveConflict = false) }
/** [force] = overwrite even if the task changed since it loaded (conflict prompt). */
fun save(force: Boolean = false) {
val current = _state.value
val form = current.toForm()
val errors = form.validate()
@@ -152,12 +199,20 @@ class TaskEditViewModel @Inject constructor(
viewModelScope.launch {
runCatching {
val id = editingTaskId
if (id == null) repository.createTask(form) else repository.updateTask(id, form)
if (id == null) {
repository.createTask(form)
} else {
repository.updateTask(id, form, expectedLastModified = if (force) null else baselineLastModified)
}
reminderScheduler.sync()
}.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false)
}.onFailure {
_state.value = _state.value.copy(saveFailed = true)
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
}.onFailure { error ->
_state.value = if (error is TaskConflictException) {
_state.value.copy(saveConflict = true, saveFailed = false)
} else {
_state.value.copy(saveFailed = true)
}
}
}
}
@@ -177,5 +232,6 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
isAllDay = isAllDay,
priority = priority,
parentId = parentId,
percentComplete = percentComplete,
reminderMinutesBeforeDue = reminderMinutesBeforeDue,
)

View File

@@ -49,16 +49,20 @@ class ListsViewModel @Inject constructor(
private fun buildContent(lists: List<TaskList>, openTasks: 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
// not read as an open item here. Mirrors the task list collapsing subtasks.
val topLevel = openTasks.filter { !it.isSubtask }
fun count(smart: SmartList) =
openTasks.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
topLevel.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
val smartCounts = listOf(
SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
SmartCount(SmartList.ALL, openTasks.size),
SmartCount(SmartList.ALL, topLevel.size),
)
val openByList = openTasks.groupingBy { it.listId }.eachCount()
val openByList = topLevel.groupingBy { it.listId }.eachCount()
val groups = lists
.groupBy { it.accountName }
.map { (account, accountLists) ->

View File

@@ -86,6 +86,9 @@ fun FloretNavHost(modifier: Modifier = Modifier) {
onEdit = { nav.navigate(Dest.TaskEdit.buildEdit(taskId)) },
onDeleted = { nav.popBackStack() },
onBack = { nav.popBackStack() },
// A subtask opens its own detail — another entry on the stack, with
// its own VM bound to that id (it may have children of its own).
onOpenTask = { subtaskId -> nav.navigate(Dest.TaskDetail.build(subtaskId)) },
)
}

View File

@@ -1,9 +1,15 @@
package de.jeanlucmakiola.floret.ui.tasklist
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
@@ -33,7 +39,6 @@ import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -46,21 +51,25 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberSwipeToDismissBoxState
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 kotlinx.coroutines.delay
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
@@ -81,6 +90,7 @@ import de.jeanlucmakiola.floret.domain.TaskSection
import de.jeanlucmakiola.floret.domain.TaskSections
import de.jeanlucmakiola.floret.ui.common.ListNameChip
import de.jeanlucmakiola.floret.ui.common.Position
import de.jeanlucmakiola.floret.ui.common.PriorityChip
import de.jeanlucmakiola.floret.ui.common.formatDateTime
import de.jeanlucmakiola.floret.ui.common.pastelize
import de.jeanlucmakiola.floret.ui.common.positionOf
@@ -107,6 +117,22 @@ fun TaskListScreen(
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listName = (state as? TaskListUiState.Content)?.listName
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
// gives an undo window: Undo restores it, otherwise the delete commits. A
// second delete while a chip is up commits the first (its window is over).
var undoTarget by remember { mutableStateOf<Task?>(null) }
val onRequestDelete: (Task) -> Unit = { task ->
undoTarget?.let { viewModel.commitDelete(it.taskId) }
viewModel.markPendingDelete(task)
undoTarget = task
}
LaunchedEffect(undoTarget) {
val target = undoTarget ?: return@LaunchedEffect
delay(4_000)
viewModel.commitDelete(target.taskId)
if (undoTarget?.taskId == target.taskId) undoTarget = null
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -131,10 +157,63 @@ fun TaskListScreen(
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, viewModel)
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
}
UndoChip(
visible = undoTarget != null,
onUndo = {
undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = inner.calculateBottomPadding() + 24.dp),
)
}
}
}
/**
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
* full-width snackbar) sized to its content, sliding up from the bottom centre.
*/
@Composable
private fun UndoChip(visible: Boolean, onUndo: () -> Unit, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(50),
shadowElevation = 6.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 20.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.task_deleted),
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = onUndo,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.undo),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
@@ -146,6 +225,7 @@ private fun TaskListBody(
filter: TaskFilter,
inner: PaddingValues,
onOpenTask: (Long) -> Unit,
onRequestDelete: (Task) -> Unit,
viewModel: TaskListViewModel,
) {
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
@@ -165,6 +245,12 @@ private fun TaskListBody(
val showListName = filter is TaskFilter.Smart
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
val childrenByParent = remember(state.tasks) {
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -200,17 +286,82 @@ private fun TaskListBody(
}
}
if (!collapsible || completedExpanded) {
itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task ->
TaskRow(
task = task,
position = positionOf(index, section.tasks.size),
showListName = showListName,
subtaskDone = task.subtaskDone,
subtaskTotal = task.subtaskTotal,
onToggle = { viewModel.toggleComplete(task) },
onDelete = { viewModel.delete(task) },
onClick = { onOpenTask(task.taskId) },
// Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children
// read as a grouped list nested in the list's grouped list:
// full-width, connecting to the parent above (small top corners),
// and the run rounds off (full corners) only at its genuine ends.
val rows = buildList {
val topLevel = section.tasks
topLevel.forEachIndexed { p, task ->
val children = childrenByParent[task.taskId].orEmpty()
// Only expandable when we hold every child: true in a real
// list, but a smart list may omit children due on other
// days, and a half-shown set would mislead.
val canExpand = task.subtaskTotal > 0 && children.size == task.subtaskTotal
val isExpanded = canExpand && task.taskId in expandedParents
// Top-level tasks keep their own connected run — full top
// only on the first, full bottom only on the last — so the
// task after an expanded one stays mid-run (small top). An
// expanded parent's bottom opens (small) to meet its subtasks.
val parentPosition = cornerPosition(
topFull = p == 0,
bottomFull = if (isExpanded) false else p == topLevel.lastIndex,
)
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
if (isExpanded) {
// Subtasks connect up to the parent (small top) and to
// each other; the inline add row below closes the group,
// so every subtask is mid-run (small top + bottom).
children.sortedBy { it.isCompleted }.forEach { sub ->
add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = false)))
}
// The add row rounds the group off (full bottom),
// independent of whatever top-level task follows.
add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
}
}
}
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
val lastInRun = index == rows.lastIndex
when (val row = r.row) {
is ListRow.Parent -> TaskRow(
task = row.task,
position = r.position,
lastInRun = lastInRun,
showListName = showListName,
subtaskDone = row.task.subtaskDone,
subtaskTotal = row.task.subtaskTotal,
expanded = row.expanded,
onToggleExpand = if (row.expandable) {
{
expandedParents = if (row.task.taskId in expandedParents) {
expandedParents - row.task.taskId
} else {
expandedParents + row.task.taskId
}
}
} else {
null
},
onToggle = { viewModel.toggleComplete(row.task) },
onDelete = { onRequestDelete(row.task) },
onClick = { onOpenTask(row.task.taskId) },
)
is ListRow.Sub -> SubtaskRow(
task = row.task,
position = r.position,
lastInRun = lastInRun,
onToggle = { viewModel.toggleComplete(row.task) },
onClick = { onOpenTask(row.task.taskId) },
)
is ListRow.AddSub -> AddSubtaskRow(
position = r.position,
lastInRun = lastInRun,
onAdd = { viewModel.quickAddSubtask(row.parent, it) },
)
}
}
}
}
@@ -269,9 +420,12 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
private fun TaskRow(
task: Task,
position: Position,
lastInRun: Boolean,
showListName: Boolean,
subtaskDone: Int,
subtaskTotal: Int,
expanded: Boolean,
onToggleExpand: (() -> Unit)?,
onToggle: () -> Unit,
onDelete: () -> Unit,
onClick: () -> Unit,
@@ -279,23 +433,28 @@ private fun TaskRow(
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
when (value) {
SwipeToDismissBoxValue.StartToEnd -> { onToggle(); false } // toggle, snap back
SwipeToDismissBoxValue.EndToStart -> { onDelete(); true } // delete, dismiss
// Both snap back: complete toggles status; delete hands off to the
// undo flow, which removes the row from the data. Never *confirm*
// the dismiss — a terminal dismissed state strands a stale
// background bar and won't re-arm when the row is restored.
SwipeToDismissBoxValue.StartToEnd -> { onToggle(); false }
SwipeToDismissBoxValue.EndToStart -> { onDelete(); false }
SwipeToDismissBoxValue.Settled -> false
}
},
)
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
// A small gap below every row but the run's last — including a group-ending
// Bottom that's followed by a fresh card, so the two groups read apart.
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox(
state = dismissState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
backgroundContent = { SwipeBackground(dismissState.targetValue, task.isCompleted) },
// dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) {
TaskRowContent(task, position, showListName, subtaskDone, subtaskTotal, onToggle, onClick)
TaskRowContent(task, position, showListName, subtaskDone, subtaskTotal, expanded, onToggleExpand, onToggle, onClick)
}
}
@@ -307,6 +466,8 @@ private fun TaskRowContent(
showListName: Boolean,
subtaskDone: Int,
subtaskTotal: Int,
expanded: Boolean,
onToggleExpand: (() -> Unit)?,
onToggle: () -> Unit,
onClick: () -> Unit,
) {
@@ -317,7 +478,8 @@ private fun TaskRowContent(
val dark = isSystemInDarkTheme()
val due = task.due?.formatDateTime(task.isAllDay)
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
val hasSupporting = due != null || listName != null || subtaskTotal > 0
val hasSupporting = due != null || listName != null || subtaskTotal > 0 ||
task.priority != Priority.NONE
Surface(
onClick = onClick,
@@ -368,6 +530,9 @@ private fun TaskRowContent(
horizontalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
if (task.priority != Priority.NONE) {
PriorityChip(task.priority, priorityLabel(task.priority))
}
if (due != null) {
Text(
text = due,
@@ -380,21 +545,16 @@ private fun TaskRowContent(
}
}
}
priorityFlag(task.priority)?.let { flag ->
Spacer(Modifier.width(8.dp))
flag()
// Trailing area is only ever the expand control, so the row's right
// edge stays put whether or not the task has children (priority now
// rides in the supporting chip row instead of a shifting trailing flag).
if (onToggleExpand != null) {
SubtaskExpandButton(expanded = expanded, onToggle = onToggleExpand)
}
}
}
}
/** A flag tinted by priority, or nothing for [Priority.NONE]. */
@Composable
private fun priorityFlag(priority: Priority): (@Composable () -> Unit)? {
if (priority == Priority.NONE) return null
return { Icon(Icons.Rounded.Flag, contentDescription = priorityLabel(priority), tint = priorityTint(priority)) }
}
/** A small neutral pill showing subtask progress, e.g. "3 / 5". */
@Composable
private fun SubtaskCountChip(done: Int, total: Int) {
@@ -422,13 +582,186 @@ private fun SubtaskCountChip(done: Int, total: Int) {
}
}
/** Shared priority colour so the flag reads the same in the list and the detail view. */
/**
* The inline "add a subtask" row that closes an expanded group — a [BasicTextField]
* aligned with the subtask titles (leading + where the checkbox sits), submitting
* on the tick or the IME action. Same grouped-card segment as the rows around it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun priorityTint(priority: Priority): Color = when (priority) {
Priority.NONE -> MaterialTheme.colorScheme.onSurfaceVariant
Priority.HIGH -> MaterialTheme.colorScheme.error
Priority.MEDIUM -> MaterialTheme.colorScheme.tertiary
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
private fun AddSubtaskRow(
position: Position,
lastInRun: Boolean,
onAdd: (String) -> Unit,
) {
var text by remember { mutableStateOf("") }
fun submit() {
if (text.isNotBlank()) {
onAdd(text.trim())
text = ""
}
}
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
Surface(
shape = cardShape(position, 22.dp, 6.dp),
// Same tone as the subtask rows it closes, so the group reads as one unit.
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.width(5.dp)) // the parents' colour-bar slot, kept for alignment
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(4.dp))
BasicTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { submit() }),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (text.isEmpty()) {
Text(
text = stringResource(R.string.add_subtask_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
inner()
},
)
if (text.isNotBlank()) {
IconButton(onClick = ::submit) {
Icon(
Icons.Rounded.Check,
contentDescription = stringResource(R.string.cd_add_task),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
/** The trailing expand/collapse control for a parent row — a rotating chevron. */
@Composable
private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) {
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtaskExpand")
IconButton(onClick = onToggle) {
Icon(
imageVector = Icons.Rounded.ExpandMore,
contentDescription = stringResource(
if (expanded) R.string.cd_collapse_subtasks else R.string.cd_expand_subtasks,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
}
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
private sealed interface ListRow {
val key: Long
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
override val key: Long get() = task.taskId
}
data class Sub(val task: Task) : ListRow {
override val key: Long get() = task.taskId
}
/** The inline "add a subtask" row that closes an expanded group. */
data class AddSub(val parent: Task) : ListRow {
// Negative so it never collides with a real (positive) provider task id.
override val key: Long get() = -parent.taskId
}
}
/** A flattened row paired with the grouped-card corners it should render with. */
private data class RenderRow(val row: ListRow, val position: Position)
/**
* Map independent top/bottom corner choices to a [Position]. `full` = the big
* outer corner radius (a run's start/end); otherwise the small connecting radius.
* Letting each edge be chosen on its own lets a subtask group round off (full
* bottom) while the next top-level task keeps a small top and stays mid-run.
*/
private fun cornerPosition(topFull: Boolean, bottomFull: Boolean): Position = when {
topFull && bottomFull -> Position.Alone
topFull -> Position.Top
bottomFull -> Position.Bottom
else -> Position.Middle
}
/**
* One subtask row inside an expanded parent: a full-width grouped-card segment
* (same tonal surface, corners-from-[position], press-morph as the task rows), so
* it's part of the same grouped run. The only difference is a leading indent
* before the checkbox to convey nesting — the card width is preserved.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SubtaskRow(
task: Task,
position: Position,
lastInRun: Boolean,
onToggle: () -> Unit,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "subFullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "subSmallCorner")
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
Surface(
onClick = onClick,
shape = cardShape(position, full, small),
// A step below the parents' surfaceContainerHigh so the subtask group
// reads as a distinct, recessive tone — the nesting cue alongside shape.
color = MaterialTheme.colorScheme.surfaceContainer,
interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = 12.dp, end = 16.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// No colour bar — that absence (with the grouped nesting) marks the row
// as a subtask. Reserve the bar's 5dp so the checkbox still lines up
// with the parent rows' checkboxes rather than indenting.
Spacer(Modifier.width(5.dp))
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Spacer(Modifier.width(4.dp))
Text(
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
color = if (task.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
}
}
}
/** The colored reveal behind a swiping row: complete on the left, delete on the right. */

View File

@@ -46,6 +46,9 @@ class TaskListViewModel @Inject constructor(
private val filter = MutableStateFlow<TaskFilter?>(null)
/** Tasks swiped to delete but not yet committed — hidden until the undo lapses. */
private val pendingDeletes = MutableStateFlow<Set<Long>>(emptySet())
val state: StateFlow<TaskListUiState> =
filter.filterNotNull()
.flatMapLatest { f ->
@@ -58,7 +61,15 @@ class TaskListViewModel @Inject constructor(
is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) }
}
content
// Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit.
combine(content, pendingDeletes) { st, pending ->
if (st is TaskListUiState.Content) {
st.copy(tasks = st.tasks.filter { it.taskId !in pending })
} else {
st
}
}
.onStart { emit(TaskListUiState.Loading) }
.catch { emit(TaskListUiState.Failure) }
}
@@ -70,8 +81,24 @@ class TaskListViewModel @Inject constructor(
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
}
fun delete(task: Task) = viewModelScope.launch {
runCatching { repository.deleteTask(task.taskId) }
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
fun markPendingDelete(task: Task) {
pendingDeletes.value = pendingDeletes.value + task.taskId
}
/** Undo — stop hiding the task; it returns to the list. */
fun undoDelete(taskId: Long) {
pendingDeletes.value = pendingDeletes.value - taskId
}
/**
* Commit the delete once the undo window passes. The id stays in the pending
* set: the task is gone from the provider so it can't reappear, and removing
* it could briefly un-hide the row before the provider change propagates.
*/
fun commitDelete(taskId: Long) = viewModelScope.launch {
if (taskId !in pendingDeletes.value) return@launch
runCatching { repository.deleteTask(taskId) }
}
/** Inline "add task" — title only, into [listId]. */
@@ -79,4 +106,14 @@ class TaskListViewModel @Inject constructor(
if (title.isBlank() || listId <= 0L) return@launch
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
}
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
if (title.isBlank() || parent.listId <= 0L) return@launch
runCatching {
repository.createTask(
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
)
}
}
}

View File

@@ -29,9 +29,14 @@
<!-- Task list: inline add + swipe -->
<string name="add_task_hint">Add a task</string>
<string name="add_subtask_hint">Add a subtask</string>
<string name="cd_add_task">Add task</string>
<string name="cd_complete">Complete</string>
<string name="cd_reopen">Reopen</string>
<string name="task_deleted">Deleted</string>
<string name="undo">Undo</string>
<string name="cd_expand_subtasks">Show subtasks</string>
<string name="cd_collapse_subtasks">Hide subtasks</string>
<!-- Sections (due-date buckets) -->
<string name="section_overdue">Overdue</string>
@@ -55,6 +60,8 @@
<string name="detail_priority">Priority</string>
<string name="detail_progress">Progress</string>
<string name="detail_subtasks">Subtasks</string>
<string name="detail_parent">Parent task</string>
<string name="detail_part_of">Part of</string>
<string name="percent_complete">%1$d%%</string>
<!-- Task edit -->
@@ -63,6 +70,11 @@
<string name="edit_due_label">Due</string>
<string name="edit_all_day">All day</string>
<string name="edit_priority_label">Priority</string>
<string name="edit_progress_label">Progress</string>
<string name="edit_parent_label">Parent task</string>
<string name="edit_parent_none">None (top-level)</string>
<string name="edit_parent_search">Search tasks</string>
<string name="edit_parent_empty">No matching tasks in this list.</string>
<string name="edit_reminder_label">Reminder</string>
<string name="edit_set">Set</string>
<string name="edit_clear">Clear</string>
@@ -72,6 +84,11 @@
<string name="close">Close</string>
<string name="dialog_cancel">Cancel</string>
<!-- Save conflict -->
<string name="edit_conflict_title">Task changed elsewhere</string>
<string name="edit_conflict_body">This task was updated somewhere else (a sync or another app) since you opened it. Overwrite those changes with your edits?</string>
<string name="edit_conflict_overwrite">Overwrite</string>
<!-- Reminder offsets -->
<string name="reminder_none">None</string>
<string name="reminder_at_due">At time of task</string>

View File

@@ -28,6 +28,50 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin")
}
@Test
fun `percent complete is written and clamped to 0-100`() {
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 140),
tzId = "UTC",
)
assertThat(values[Tasks.PERCENT_COMPLETE]).isEqualTo(100)
val unset = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
assertThat(unset[Tasks.PERCENT_COMPLETE]).isNull()
}
@Test
fun `progress keeps completion in sync both ways`() {
// 100% completes the task (timestamp left to the provider / existing value).
val done = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 100),
tzId = "UTC",
)
assertThat(done[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(done.containsKey(Tasks.COMPLETED)).isFalse()
// Below 100% reopens it and clears the completion timestamp.
val reopened = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 75),
tzId = "UTC",
)
assertThat(reopened[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_IN_PROCESS)
assertThat(reopened[Tasks.COMPLETED]).isNull()
// No percent in the form ⇒ status is left to the complete toggle.
val untouched = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
assertThat(untouched.containsKey(Tasks.STATUS)).isFalse()
}
@Test
fun `parent id is written so a task can be filed under another`() {
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, parentId = 7L),
tzId = "UTC",
)
assertThat(values[Tasks.PARENT_ID]).isEqualTo(7L)
}
@Test
fun `all-day task clears the timezone`() {
val form = TaskForm(

View File

@@ -35,4 +35,13 @@ class TaskFormTest {
val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
}
@Test
fun `progress and parent auto-reveal only when they carry a value`() {
assertThat(TaskForm(title = "x", listId = 1).populatedFields()).isEmpty()
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 0, parentId = 0).populatedFields())
.isEmpty()
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
}
}

View File

@@ -11,9 +11,12 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
## Current state (one line)
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**; the Material 3 Expressive UI is being
built screen by screen on top of it. App builds, gates on provider/permission,
and navigates lists → task list → detail / edit through base screen scaffolds.
provider is **done and unit-tested**, and the Material 3 Expressive UI is built
through **M4**: lists → task list (swipe gestures, inline add, smart-list section
headers) → detail / edit with full CRUD, date-time pickers, priority,
percent-complete, conflict-safe saves, per-task reminders, and subtask
create + reparent. Remaining work is M5 (notification / exact-alarm onboarding,
default reminder UI) and M6 (Settings screen, Glance widget, i18n, release).
---
@@ -39,38 +42,81 @@ The complete non-visual stack:
- JVM unit tests across mappers, filtering, sorting, form, value mapping, and
day windows.
### 🚧 M2 — Screens: lists, task list, complete & CRUD
Replace the functional scaffold with the real Material 3 Expressive UI, screen
by screen, against the already-built ViewModels.
### M2 — Screens: lists, task list, complete & CRUD
The real Material 3 Expressive UI, built screen by screen against the M1
ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- ✅ Navigation host wiring (`FloretNavHost` + `Dest` route table: lists → task
list → detail / edit, each binding its M1 ViewModel from route args).
- 🚧 Task list screen — base scaffold lands (checkbox rows, toggle-complete, open
/ new-task nav). Remaining: swipe-to-complete / -delete, inline add field,
section headers for smart lists.
- 🚧 Detail + edit screens — base scaffolds land (detail shows title /
description / subtasks with edit+delete; edit has title / description / save).
Remaining: full CRUD fields and local-list creation wired to the UI.
- Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
-delete (`SwipeToDismissBox`), inline add field (`InlineAdd``quickAdd`),
smart-list section headers (`TaskSections`).
- Detail + edit screens — detail shows title / description / subtasks with
edit+delete; edit has the full CRUD form (title, description, save) wired to a
list picker.
### M3 — Detail / edit polish
Task detail and edit screens: due/start date-time pickers, priority,
percent-complete, conflict-safe saves (re-check before overwrite), smart-list
section presentation.
### M3 — Detail / edit polish
- ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`).
- ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`;
M3 has no priority role, only `error`). A shared `ui/common/PriorityChip` on the
list and detail screens, and the edit form's M3 segmented selector tints its
active segment in the chosen level's hue.
- ✅ Smart-list section presentation (`TaskSections` headers on the task list).
- ✅ Percent-complete field — optional "Progress" slider (5% detents) on the edit
form; writes `Tasks.PERCENT_COMPLETE` (clamped 0100, status left to the
complete toggle).
- ✅ Conflict-safe saves — `updateTask` re-checks `last_modified` against the
value captured when the form loaded and throws `TaskConflictException`; the
editor offers overwrite-or-cancel instead of clobbering an external change.
### M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI: indentation, create subtask, reparent. The
data layer already reads `parentId`; this is the trickiest UI piece.
### M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`.
- ✅ Create subtask (`AddSubtaskField``TaskDetailViewModel.addSubtask` sets
`parentId`); subtasks render as a grouped section in the detail screen. Tapping
a subtask opens its own detail (a new `TaskDetail` entry, so it can have
children too), and the subtask's detail shows a tappable "Part of …" parent
card so it never reads as a stray standalone task.
- ✅ Inline expansion on the task list — a parent row has a dedicated expand
button (trailing chevron, separate from the count chip). Each section flattens
into one grouped run (parents + their expanded children) and corners are chosen
per-edge (`cornerPosition`): top-level tasks keep their own run, an expanded
parent opens its bottom to its children, and the child group rounds off on its
last segment while the next top-level task stays mid-run. Children are
full-width, set a step down in tone (`surfaceContainer` vs the parents'
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
row (always on for now; an opt-out toggle lands with the M6 settings screen).
Offered only where the list holds all the children (a real list; smart lists
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
parent for the detail card.
- ✅ Reparent — a full-width, searchable "Parent task" sheet on the edit form
groups active candidates by due-date section (Overdue / Today / Upcoming / No
date) and files a task under any top-level task in its list (or "None" to
promote it); switching list clears the now-invalid parent. Candidates stay
active + top-level to keep nesting one level deep, matching the detail screen.
### M5 — Reminders onboarding & polish
The engine exists (M1). Remaining: the `POST_NOTIFICATIONS` + exact-alarm
onboarding flow, per-task / default reminder-offset settings UI, and
end-to-end verification on device.
### 🚧 M5 — Reminders onboarding & polish
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
`DueReminderReceiver`, `TaskNotifier`).
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog``TaskForm`
`reminderMinutesBeforeDue`).
-`POST_NOTIFICATIONS` + exact-alarm onboarding flow — currently only graceful
runtime checks (`TaskNotifier.canPost`, `canScheduleExactAlarms`), no
user-facing gate.
- ⬜ Default reminder-offset settings UI — `SettingsPrefs`/`SettingsViewModel`
back it, but no Composable exposes it.
- ⬜ End-to-end verification on device.
### M6 — Settings, widget, i18n, release
Settings screen (theme / dynamic-color / language, default list, default
reminder), Glance task-list widget, translations, finalize F-Droid metadata,
confirm CI release flow.
### 🚧 M6 — Settings, widget, i18n, release
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
- ⬜ Settings screen — `SettingsViewModel` exists but no `SettingsScreen`
Composable and it is not in the nav graph (only used by `MainActivity` for
app-level theming). Needs theme / dynamic-color / language, default list,
default reminder, and the opt-out toggle for the task list's inline
add-a-subtask row (on by default since M4).
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
### ⬜ Posture B (separate track, later)
Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;