M4: percent-complete, conflict-safe saves & subtask reparent
All checks were successful
CI / ci (push) Successful in 6m49s
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:
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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) ------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
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)
|
||||
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, 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. */
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user