diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b3797..024a647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/Failures.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/Failures.kt index 98942fe..6859a32 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/Failures.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/Failures.kt @@ -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") diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapper.kt index 9d92981..3045480 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapper.kt @@ -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()) diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepository.kt index 67c90fc..72b226b 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepository.kt @@ -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 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 diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepositoryImpl.kt index 9c0dda1..15aa521 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/data/tasks/TasksRepositoryImpl.kt @@ -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 = 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt index 2777437..4eeb306 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/Models.kt @@ -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, + val parent: Task? = null, ) // --- pure provider <-> domain value mapping (unit-testable) ------------------ diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt index 825c441..1852152 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt @@ -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 = 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/PriorityChip.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/PriorityChip.kt new file mode 100644 index 0000000..b659fe3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/PriorityChip.kt @@ -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() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailScreen.kt index c95dd43..4368e57 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailScreen.kt @@ -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() } + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt index c9e1cff..385f256 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt @@ -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(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, + 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt index d0293a5..47d6d17 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt @@ -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 = emptyList(), + /** Top-level tasks in the current list this task can be filed under (reparent). */ + val parentCandidates: List = emptyList(), /** Optional sections currently shown (core Title / List / When are always on). */ val visibleFields: Set = emptySet(), /** Optional sections still tucked behind "More fields". */ val hiddenFields: List = emptyList(), val errors: Set = 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 = 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 { + 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsViewModel.kt index a7a69ad..f1aa332 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsViewModel.kt @@ -49,16 +49,20 @@ class ListsViewModel @Inject constructor( private fun buildContent(lists: List, openTasks: List): 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) -> diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt index 3c8e4b1..e5d032f 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt @@ -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)) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt index 620d864..3e4bd21 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt @@ -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(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()) } + 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt index a70be71..02b79df 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt @@ -46,6 +46,9 @@ class TaskListViewModel @Inject constructor( private val filter = MutableStateFlow(null) + /** Tasks swiped to delete but not yet committed — hidden until the undo lapses. */ + private val pendingDeletes = MutableStateFlow>(emptySet()) + val state: StateFlow = 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), + ) + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c8fd10..277fd9e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,9 +29,14 @@ Add a task + Add a subtask Add task Complete Reopen + Deleted + Undo + Show subtasks + Hide subtasks Overdue @@ -55,6 +60,8 @@ Priority Progress Subtasks + Parent task + Part of %1$d%% @@ -63,6 +70,11 @@ Due All day Priority + Progress + Parent task + None (top-level) + Search tasks + No matching tasks in this list. Reminder Set Clear @@ -72,6 +84,11 @@ Close Cancel + + Task changed elsewhere + This task was updated somewhere else (a sync or another app) since you opened it. Overwrite those changes with your edits? + Overwrite + None At time of task diff --git a/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapperTest.kt index 81a001d..f570cce 100644 --- a/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/floret/data/tasks/TaskWriteMapperTest.kt @@ -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( diff --git a/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFormTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFormTest.kt index c749d01..e1147d4 100644 --- a/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskFormTest.kt @@ -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) + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2f55ab2..93a9144 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 0–100, 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`;