From a19b1373a4356e035743226b1dbb8eca82d8e6d6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 18 Jun 2026 16:03:37 +0200 Subject: [PATCH] M2: build out task list, detail & edit screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the base scaffolds with the real Material 3 Expressive UI on the existing M1 ViewModels. Task list: due-date section headers (collapsible Completed), grouped tonal rows with a list-colour accent bar, swipe-to-complete / -delete, inline add, list-name chip and subtask "done/total" chip in mixed views (children folded into their parent). New pure TaskSections grouping + unit test. Detail: each fact its own tonal card with a leading icon (Calendula pattern) — when/list/priority/progress/description; progress shows only with subtasks. Subtasks are a connected grouped run whose first segment is the expand toggle, with an accent-tinted inline add row; completed subtasks sink to the bottom. Edit: title, description, list dropdown, start/due date-time + all-day, priority segmented buttons, reminder offset, validation; IME auto-scroll. Extract GroupedSurface from GroupedRow so the connected-row look is reusable at any width; add ListNameChip + date-time format/picker helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../floret/domain/TaskSections.kt | 39 ++ .../floret/ui/common/DateTimeField.kt | 166 ++++++ .../floret/ui/common/DateTimeFormat.kt | 28 + .../floret/ui/common/GroupedList.kt | 98 ++-- .../floret/ui/common/ListChip.kt | 28 + .../floret/ui/detail/TaskDetailScreen.kt | 412 ++++++++++++- .../floret/ui/detail/TaskDetailViewModel.kt | 12 + .../floret/ui/edit/TaskEditScreen.kt | 206 ++++++- .../floret/ui/tasklist/TaskListScreen.kt | 544 ++++++++++++++++-- .../floret/ui/tasklist/TaskListViewModel.kt | 21 +- app/src/main/res/values/strings.xml | 55 ++ .../floret/domain/TaskSectionsTest.kt | 47 ++ 12 files changed, 1537 insertions(+), 119 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/floret/domain/TaskSections.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeField.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeFormat.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/floret/domain/TaskSectionsTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskSections.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskSections.kt new file mode 100644 index 0000000..167e379 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskSections.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.floret.domain + +import kotlin.time.Instant + +/** A due-date bucket a task list groups its rows under. */ +enum class TaskSection { OVERDUE, TODAY, UPCOMING, NO_DATE, COMPLETED } + +/** A non-empty run of tasks under one [TaskSection], already in display order. */ +data class SectionedTasks(val section: TaskSection, val tasks: List) + +/** + * Group already-sorted tasks into due-date sections (the standard task-app + * presentation). Pure + side-effect-free so it unit-tests with a fixed clock, + * mirroring [TaskFiltering] / [DayWindow]. Completed tasks always fall to the + * [TaskSection.COMPLETED] bucket regardless of their due date. + * + * [todayStart] is local midnight today and [todayEnd] local midnight tomorrow + * (compute with [DayWindow]). Only non-empty sections are returned, in the + * fixed order Overdue → Today → Upcoming → No date → Completed. + */ +object TaskSections { + + fun of(tasks: List, todayStart: Instant, todayEnd: Instant): List { + val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) } + return TaskSection.entries.mapNotNull { section -> + bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) } + } + } + + fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection { + if (task.isCompleted) return TaskSection.COMPLETED + val due = task.due ?: return TaskSection.NO_DATE + return when { + due < todayStart -> TaskSection.OVERDUE + due < todayEnd -> TaskSection.TODAY + else -> TaskSection.UPCOMING + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeField.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeField.kt new file mode 100644 index 0000000..6819ea8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeField.kt @@ -0,0 +1,166 @@ +package de.jeanlucmakiola.floret.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import de.jeanlucmakiola.floret.R +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset +import kotlin.time.Instant + +private val zone: ZoneId get() = ZoneId.systemDefault() + +private fun Instant.toLocalDate(): LocalDate = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate() + +private fun Instant.toLocalTime(): LocalTime = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime() + +private fun localToInstant(date: LocalDate, time: LocalTime): Instant = + Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli()) + +/** + * A labelled date(-time) field for the edit form: a tonal row showing the + * current value (or nothing), tappable to pick a date and — unless [allDay] — + * a time. A clear affordance appears once a value is set. Emits `null` when + * cleared. Styled to match the app's rounded tonal family. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DateTimeField( + label: String, + value: Instant?, + allDay: Boolean, + onChange: (Instant?) -> Unit, + modifier: Modifier = Modifier, +) { + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + var pendingDate by remember { mutableStateOf(null) } + + Surface( + onClick = { showDatePicker = true }, + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set), + style = MaterialTheme.typography.bodyLarge, + ) + } + if (value != null) { + IconButton(onClick = { onChange(null) }) { + Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear)) + } + } + } + } + + if (showDatePicker) { + val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())) + .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + showDatePicker = false + val millis = dateState.selectedDateMillis ?: return@TextButton + val date = java.time.Instant.ofEpochMilli(millis) + .atZone(ZoneOffset.UTC).toLocalDate() + if (allDay) { + onChange(localToInstant(date, LocalTime.MIDNIGHT)) + } else { + pendingDate = date + showTimePicker = true + } + }, + ) { Text(stringResource(android.R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) { DatePicker(state = dateState) } + } + + if (showTimePicker) { + val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()) + val timeState = rememberTimePickerState( + initialHour = base.toLocalTime().hour, + initialMinute = base.toLocalTime().minute, + ) + Dialog(onDismissRequest = { showTimePicker = false }) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TimePicker(state = timeState) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { showTimePicker = false }) { + Text(stringResource(android.R.string.cancel)) + } + TextButton(onClick = { + showTimePicker = false + val date = pendingDate ?: return@TextButton + onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute))) + }) { Text(stringResource(android.R.string.ok)) } + } + } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeFormat.kt new file mode 100644 index 0000000..bdf347d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeFormat.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.floret.ui.common + +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import kotlin.time.Instant + +/** + * Locale-aware display formatting for the [kotlin.time.Instant]s the domain + * uses. Kept tiny and dependency-free; the data layer stores instants, the UI + * renders them in the device's zone and locale. + */ +private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + +private fun Instant.atSystemZone() = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()) + .atZone(ZoneId.systemDefault()) + +/** Medium localized date, e.g. "18 Jun 2026". */ +fun Instant.formatDate(): String = atSystemZone().format(dateFormatter) + +/** Short localized time, e.g. "14:30". */ +fun Instant.formatTime(): String = atSystemZone().format(timeFormatter) + +/** Date alone for all-day items, otherwise date + time. */ +fun Instant.formatDateTime(allDay: Boolean): String = + if (allDay) formatDate() else "${formatDate()} · ${formatTime()}" diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/GroupedList.kt index 944b9af..7d74f20 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/GroupedList.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/GroupedList.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -36,10 +37,51 @@ fun positionOf(index: Int, count: Int): Position = when { else -> Position.Middle } +/** Corner radii for a grouped segment: full at the group's outer edges, small between. */ +fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) { + Position.Alone -> RoundedCornerShape(full) + Position.Top -> RoundedCornerShape(topStart = full, topEnd = full, bottomStart = small, bottomEnd = small) + Position.Middle -> RoundedCornerShape(small) + Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full) +} + /** - * One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose - * corner radii come from its [position] (so a run of rows reads as a single - * rounded card). Corners morph further on press — the expressive shape play. + * One segment of a grouped list: a tonal [Surface] whose corner radii come from + * [position] (so a run reads as a single rounded card), with a small gap below + * all but the last. Corners morph rounder on press — the expressive shape play. + * The caller supplies [content] and any horizontal inset via [modifier], so the + * same primitive serves both the 16dp-inset lists and full-width detail cards. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupedSurface( + position: Position, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + color: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + content: @Composable () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner") + val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner") + val shape = groupedShape(position, full, small) + val gap = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> Modifier + } + val base = modifier.fillMaxWidth().then(gap) + if (onClick != null) { + Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() } + } else { + Surface(color = color, shape = shape, modifier = base) { content() } + } +} + +/** + * One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the + * tonal, position-shaped, press-morphing family container). Insets 16dp like + * the lists overview. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,24 +96,6 @@ fun GroupedRow( trailing: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, ) { - val interaction = remember { MutableInteractionSource() } - val pressed by interaction.collectIsPressedAsState() - val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner") - val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner") - val shape = when (position) { - Position.Alone -> RoundedCornerShape(full) - Position.Top -> RoundedCornerShape( - topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, - ) - Position.Middle -> RoundedCornerShape(small) - Position.Bottom -> RoundedCornerShape( - topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, - ) - } - val gap = when (position) { - Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) - Position.Bottom, Position.Alone -> Modifier - } val itemColors = if (selected) { ListItemDefaults.colors( containerColor = Color.Transparent, @@ -83,7 +107,17 @@ fun GroupedRow( } else { ListItemDefaults.colors(containerColor = Color.Transparent) } - val item: @Composable () -> Unit = { + val containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + GroupedSurface( + position = position, + modifier = modifier.padding(horizontal = 16.dp), + onClick = onClick, + color = containerColor, + ) { ListItem( headlineContent = { Text(title) }, supportingContent = summary?.let { text -> { Text(text) } }, @@ -93,24 +127,4 @@ fun GroupedRow( modifier = Modifier.heightIn(min = minHeight), ) } - val base = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .then(gap) - val containerColor = if (selected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - } - if (onClick != null) { - Surface( - onClick = onClick, - color = containerColor, - shape = shape, - interactionSource = interaction, - modifier = base, - ) { item() } - } else { - Surface(color = containerColor, shape = shape, modifier = base) { item() } - } } diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ListChip.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ListChip.kt index d9a394c..1a987bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ListChip.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ListChip.kt @@ -3,17 +3,23 @@ package de.jeanlucmakiola.floret.ui.common import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Checklist 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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp /** @@ -53,3 +59,25 @@ fun ListColorChip(color: Int, modifier: Modifier = Modifier) { ) } } + +/** + * A small pill naming the task's list, filled in the list's (pastelised) colour. + * The label flips black/white by the fill's luminance so it stays legible on any + * hue in light and dark. Used in mixed views (smart lists) where rows span lists. + */ +@Composable +fun ListNameChip(name: String, color: Int, modifier: Modifier = Modifier) { + val dark = isSystemInDarkTheme() + val fill = pastelize(color, dark) + val onFill = if (fill.luminance() > 0.5f) Color.Black else Color.White + Surface(color = fill, shape = RoundedCornerShape(50), modifier = modifier) { + Text( + text = name, + color = onFill, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } +} 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 4e837ae..42eef41 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 @@ -1,38 +1,86 @@ package de.jeanlucmakiola.floret.ui.detail +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width 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.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ListAlt +import androidx.compose.material.icons.automirrored.rounded.Notes +import androidx.compose.material.icons.rounded.Add +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.Edit +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Flag +import androidx.compose.material.icons.rounded.Percent +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable 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 androidx.compose.ui.Alignment +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration 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.Task import de.jeanlucmakiola.floret.domain.TaskDetail +import de.jeanlucmakiola.floret.ui.common.GroupedSurface +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. **Base scaffold** — title, description and subtask titles, - * with edit / delete in the top bar, against the already-built - * [TaskDetailViewModel]. M3 fleshes this out with dates, priority, - * percent-complete and subtask presentation. + * One task's detail. Each fact is its own tonal card with a leading icon (the + * Calendula detail pattern); only genuinely-grouped things — the subtasks — use + * the grouped-row run. Edit / delete live in the top bar. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -82,46 +130,366 @@ fun TaskDetailScreen( TaskDetailUiState.Loading -> Unit TaskDetailUiState.NotFound -> CenteredMessage(stringResource(R.string.task_detail_not_found), inner) - is TaskDetailUiState.Content -> DetailBody(s.detail, inner) + is TaskDetailUiState.Content -> DetailBody( + detail = s.detail, + inner = inner, + onToggle = { viewModel.toggleComplete(s.detail.task) }, + onToggleSubtask = { viewModel.toggleComplete(it) }, + onAddSubtask = { viewModel.addSubtask(it) }, + onOpenSubtask = { /* reserved: subtask navigation lands with the subtasks milestone */ }, + ) } } } @Composable -private fun DetailBody(detail: TaskDetail, inner: androidx.compose.foundation.layout.PaddingValues) { +private fun DetailBody( + detail: TaskDetail, + inner: androidx.compose.foundation.layout.PaddingValues, + onToggle: () -> Unit, + onToggleSubtask: (Task) -> Unit, + onAddSubtask: (String) -> Unit, + onOpenSubtask: (Long) -> Unit, +) { val task = detail.task + val dark = isSystemInDarkTheme() + val accent = pastelize(task.effectiveColor, dark) + val gap = 12.dp + var subtasksExpanded by rememberSaveable { mutableStateOf(true) } + Column( modifier = Modifier .fillMaxSize() .padding(inner) + // Shrink the scroll viewport by the keyboard so the focused add-subtask + // field scrolls into view above the IME instead of being hidden. + .imePadding() .verticalScroll(rememberScrollState()) - .padding(24.dp), + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), ) { - Text( - text = task.title.ifBlank { stringResource(R.string.task_untitled) }, - style = MaterialTheme.typography.headlineSmall, + // Title + complete toggle, with a short list-coloured accent beneath. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) + Text( + text = task.title.ifBlank { stringResource(R.string.task_untitled) }, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(10.dp)) + Box( + modifier = Modifier + .width(48.dp) + .height(3.dp) + .background(accent, RoundedCornerShape(2.dp)), ) - task.description?.takeIf { it.isNotBlank() }?.let { + + Spacer(Modifier.height(20.dp)) + + // One card per fact; gaps between, no shared container (they aren't a group). + val cards = buildList<@Composable () -> Unit> { + // When — the Calendula time card: date primary, time(s) secondary. + taskWhenLines(task)?.let { (primary, secondary) -> + add { + DetailCard(icon = Icons.Rounded.Schedule, iconContentDescription = stringResource(R.string.detail_due)) { + Text(primary, style = MaterialTheme.typography.titleMedium) + if (secondary != null) { + Spacer(Modifier.height(2.dp)) + Text( + secondary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // List — icon tinted in the list colour conveys identity (no extra dot). + add { + DetailCard( + icon = Icons.Rounded.Checklist, + iconTint = accent, + iconContentDescription = stringResource(R.string.detail_list), + ) { + Text( + text = task.listName ?: stringResource(R.string.detail_list), + style = MaterialTheme.typography.titleMedium, + ) + } + } + + // Priority — the flag moves to the front, tinted like the list rows. + 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) + } + } + } + + // 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) + add { + DetailCard(icon = Icons.Rounded.Percent, iconContentDescription = stringResource(R.string.detail_progress)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(stringResource(R.string.detail_progress), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.percent_complete, pct), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator(progress = { pct / 100f }, modifier = Modifier.fillMaxWidth()) + } + } + } + + // Description. + task.description?.takeIf { it.isNotBlank() }?.let { description -> + add { + DetailCard(icon = Icons.AutoMirrored.Rounded.Notes, iconContentDescription = null) { + Text(description, style = MaterialTheme.typography.bodyMedium) + } + } + } + + // Subtasks — a genuine group: a connected grouped run whose first + // segment is the expand/collapse toggle. + if (detail.subtasks.isNotEmpty()) { + add { + SubtasksGroup( + subtasks = detail.subtasks, + expanded = subtasksExpanded, + onToggle = { subtasksExpanded = !subtasksExpanded }, + onToggleSubtask = onToggleSubtask, + onAddSubtask = onAddSubtask, + onOpenSubtask = onOpenSubtask, + ) + } + } + } + cards.forEachIndexed { index, card -> + if (index > 0) Spacer(Modifier.height(gap)) + card() + } + } +} + +/** + * Subtasks as a connected grouped run (the GroupedRow family look) at the + * cards' width: the first segment is the expand/collapse toggle (icon, + * "Subtasks", a done/total count, chevron); the rest are the subtask rows. + */ +@Composable +private fun SubtasksGroup( + subtasks: List, + expanded: Boolean, + onToggle: () -> Unit, + onToggleSubtask: (Task) -> Unit, + onAddSubtask: (String) -> Unit, + onOpenSubtask: (Long) -> Unit, +) { + val done = subtasks.count { it.isCompleted } + // Completed subtasks sink to the bottom; stable, so order is otherwise kept. + val ordered = remember(subtasks) { subtasks.sortedBy { it.isCompleted } } + val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtasksChevron") + // Segments when expanded: header + the always-present add row + each subtask. + val count = if (expanded) subtasks.size + 2 else 1 + + // First segment: the toggle header. + GroupedSurface(position = positionOf(0, count), onClick = onToggle) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ListAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 12.dp), + text = stringResource(R.string.detail_subtasks), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = "$done / ${subtasks.size}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Rounded.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(rotation), ) } - detail.subtasks.forEach { sub -> - Text( - text = "• " + sub.title.ifBlank { stringResource(R.string.task_untitled) }, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp), - ) + } + + if (expanded) { + // Always-present add row at the top of the list, in an accent tone. + GroupedSurface( + position = positionOf(1, count), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + AddSubtaskField(onAdd = onAddSubtask) } + ordered.forEachIndexed { index, sub -> + GroupedSurface( + position = positionOf(index + 2, count), + onClick = { onOpenSubtask(sub.taskId) }, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = sub.isCompleted, onCheckedChange = { onToggleSubtask(sub) }) + Spacer(Modifier.width(4.dp)) + Text( + text = sub.title.ifBlank { stringResource(R.string.task_untitled) }, + style = MaterialTheme.typography.bodyLarge, + textDecoration = if (sub.isCompleted) TextDecoration.LineThrough else null, + color = if (sub.isCompleted) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +/** + * Inline "add a subtask" — title only; submits on the tick or the IME action. + * A [BasicTextField] (not the boxed Material field) so its text lines up exactly + * with the subtask titles, the checkbox column matched by the leading + icon. + */ +@Composable +private fun AddSubtaskField(onAdd: (String) -> Unit) { + var text by remember { mutableStateOf("") } + fun submit() { + if (text.isNotBlank()) { + onAdd(text.trim()) + text = "" + } + } + val accentOn = MaterialTheme.colorScheme.onPrimaryContainer + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { + Icon(Icons.Rounded.Add, contentDescription = null, tint = accentOn) + } + Spacer(Modifier.width(4.dp)) + BasicTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = accentOn), + cursorBrush = SolidColor(accentOn), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { submit() }), + modifier = Modifier.weight(1f), + decorationBox = { inner -> + if (text.isEmpty()) { + Text( + text = stringResource(R.string.add_task_hint), + style = MaterialTheme.typography.bodyLarge, + color = accentOn.copy(alpha = 0.7f), + ) + } + inner() + }, + ) + if (text.isNotBlank()) { + IconButton(onClick = ::submit) { + Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task), tint = accentOn) + } + } + } +} + +/** One info card: tonal container, leading icon in the gutter, value to the right. */ +@Composable +private fun DetailCard( + icon: ImageVector, + iconContentDescription: String?, + iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = iconContentDescription, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f), content = content) + } + } +} + +/** + * The task's "when", formatted like Calendula's event time card: a primary date + * line and an optional secondary time line. A start+due pair reads as a range; + * `null` when the task has no dates at all. + */ +@Composable +private fun taskWhenLines(task: Task): Pair? { + val start = task.start + val due = task.due + return when { + start != null && due != null -> { + val sameDay = start.formatDate() == due.formatDate() + val primary = if (sameDay) due.formatDate() else "${start.formatDate()} – ${due.formatDate()}" + val secondary = if (task.isAllDay) null else "${start.formatTime()} – ${due.formatTime()}" + primary to secondary + } + due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime() + start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime() + else -> null } } @Composable private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) { Box( - modifier = Modifier.fillMaxSize().padding(inner).padding(24.dp), + modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp), contentAlignment = Alignment.Center, - ) { Text(text) } + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailViewModel.kt index af4c1e7..a43b896 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/detail/TaskDetailViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.floret.domain.TaskDetail +import de.jeanlucmakiola.floret.domain.TaskForm import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -54,4 +55,15 @@ class TaskDetailViewModel @Inject constructor( fun delete(task: Task) = viewModelScope.launch { runCatching { repository.deleteTask(task.taskId) } } + + /** Add a subtask (title only) under the currently-shown task. */ + fun addSubtask(title: String) = viewModelScope.launch { + val parent = (state.value as? TaskDetailUiState.Content)?.detail?.task ?: return@launch + if (title.isBlank()) return@launch + runCatching { + repository.createTask( + TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId), + ) + } + } } 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 33874b5..6c40957 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 @@ -1,36 +1,55 @@ package de.jeanlucmakiola.floret.ui.edit +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType 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.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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.Priority +import de.jeanlucmakiola.floret.domain.TaskFormError +import de.jeanlucmakiola.floret.ui.common.DateTimeField +import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel /** - * Create / edit form. **Base scaffold** — title + description fields and save, - * against the already-built [TaskEditViewModel]; it flips `saved` for us to pop - * back. M3 layers on date-time pickers, priority, list selection, - * percent-complete and per-task reminders. + * Create / edit form. Title, description, list, start / due date-time with an + * all-day toggle, priority and a reminder offset — validated through the M1 + * [TaskEditViewModel] / [de.jeanlucmakiola.floret.domain.TaskForm]. The VM flips + * `saved` once the write lands, which pops us back. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -42,9 +61,7 @@ fun TaskEditScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() - LaunchedEffect(state.saved) { - if (state.saved) onSaved() - } + LaunchedEffect(state.saved) { if (state.saved) onSaved() } Scaffold( modifier = modifier, @@ -78,31 +95,190 @@ fun TaskEditScreen( Column( modifier = Modifier .padding(inner) + // Shrink the scroll viewport by the keyboard so the focused + // field scrolls into view above the IME instead of being hidden. + .imePadding() .verticalScroll(rememberScrollState()) - .padding(24.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { OutlinedTextField( value = state.title, onValueChange = viewModel::onTitleChange, label = { Text(stringResource(R.string.field_title)) }, singleLine = true, - isError = state.errors.isNotEmpty(), + isError = TaskFormError.BLANK_TITLE in state.errors, + supportingText = errorText(TaskFormError.BLANK_TITLE in state.errors, R.string.error_blank_title), modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( value = state.description, onValueChange = viewModel::onDescriptionChange, label = { Text(stringResource(R.string.field_description)) }, - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + modifier = Modifier.fillMaxWidth(), ) - if (state.saveFailed) { + + ListPicker( + lists = state.lists, + selectedId = state.listId, + isError = TaskFormError.NO_LIST in state.errors, + onSelect = viewModel::onListChange, + ) + + // All-day toggle + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.edit_all_day), style = MaterialTheme.typography.bodyLarge) + Switch(checked = state.isAllDay, onCheckedChange = viewModel::onAllDayChange) + } + + DateTimeField( + label = stringResource(R.string.edit_start_label), + value = state.start, + allDay = state.isAllDay, + onChange = viewModel::onStartChange, + ) + DateTimeField( + label = stringResource(R.string.edit_due_label), + value = state.due, + allDay = state.isAllDay, + onChange = viewModel::onDueChange, + ) + if (TaskFormError.DUE_BEFORE_START in state.errors) { + FieldError(R.string.error_due_before_start) + } + + // Priority + Column { Text( - text = stringResource(R.string.edit_save_failed), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 12.dp), + stringResource(R.string.edit_priority_label), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 4.dp, bottom = 6.dp), + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + Priority.entries.forEachIndexed { index, priority -> + SegmentedButton( + selected = state.priority == priority, + onClick = { viewModel.onPriorityChange(priority) }, + shape = SegmentedButtonDefaults.itemShape(index, Priority.entries.size), + ) { Text(priorityLabel(priority)) } + } + } + } + + ReminderPicker( + minutes = state.reminderMinutesBeforeDue, + onSelect = viewModel::onReminderChange, + ) + if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) { + FieldError(R.string.error_reminder_without_due) + } + + if (state.saveFailed) FieldError(R.string.edit_save_failed) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ListPicker( + lists: List, + selectedId: Long?, + isError: Boolean, + onSelect: (Long) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedName = lists.firstOrNull { it.id == selectedId }?.name.orEmpty() + + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedName, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.edit_list_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + isError = isError, + supportingText = errorText(isError, R.string.error_no_list), + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + lists.forEach { list -> + DropdownMenuItem( + text = { Text(list.name) }, + onClick = { + onSelect(list.id) + expanded = false + }, ) } } } } + +private data class ReminderOption(val labelRes: Int, val minutes: Int?) + +private val reminderOptions = listOf( + ReminderOption(R.string.reminder_none, null), + ReminderOption(R.string.reminder_at_due, 0), + ReminderOption(R.string.reminder_5_min, 5), + ReminderOption(R.string.reminder_10_min, 10), + ReminderOption(R.string.reminder_30_min, 30), + ReminderOption(R.string.reminder_1_hour, 60), + ReminderOption(R.string.reminder_1_day, 1_440), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReminderPicker(minutes: Int?, onSelect: (Int?) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val current = reminderOptions.firstOrNull { it.minutes == minutes } ?: reminderOptions.first() + + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = stringResource(current.labelRes), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.edit_reminder_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + reminderOptions.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes)) }, + onClick = { + onSelect(option.minutes) + expanded = false + }, + ) + } + } + } +} + +/** A supporting-text lambda that shows [messageRes] only when [show]. */ +@Composable +private fun errorText(show: Boolean, messageRes: Int): (@Composable () -> Unit)? = + if (show) { + { Text(stringResource(messageRes)) } + } else { + null + } + +@Composable +private fun FieldError(messageRes: Int) { + Text( + text = stringResource(messageRes), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 4.dp), + ) +} 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 7004ca5..1bab709 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,41 +1,95 @@ package de.jeanlucmakiola.floret.ui.tasklist -import androidx.compose.foundation.clickable +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape 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.rounded.Add +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 import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.floret.R +import de.jeanlucmakiola.floret.domain.DayWindow +import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.floret.domain.TaskFilter +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.formatDateTime +import de.jeanlucmakiola.floret.ui.common.pastelize +import de.jeanlucmakiola.floret.ui.common.positionOf +import java.time.ZoneId +import kotlin.time.Clock /** - * The tasks for one [TaskFilter]. **Base scaffold** — renders the rows, toggle, - * and open/new navigation against the already-built [TaskListViewModel]. The - * richer M2 UI (swipe-to-complete / -delete, inline add field, smart-list - * section headers) layers on top of this. + * The tasks for one [TaskFilter]: due-date section headers, grouped tonal rows + * with a leading checkbox, swipe-to-complete (start→end) / swipe-to-delete + * (end→start), and — for a real list — an inline add field. Renders against the + * M1 [TaskListViewModel]; actions are fire-and-forget and flow back live. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,12 +102,14 @@ fun TaskListScreen( viewModel: TaskListViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val listName = (state as? TaskListUiState.Content)?.listName Scaffold( - modifier = modifier, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( - title = { Text(titleFor(filter)) }, + MediumTopAppBar( + title = { Text(titleFor(filter, listName)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -62,6 +118,7 @@ fun TaskListScreen( ) } }, + scrollBehavior = scrollBehavior, ) }, floatingActionButton = { @@ -75,47 +132,439 @@ fun TaskListScreen( 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 -> - if (s.tasks.isEmpty()) { - CenteredMessage(stringResource(R.string.task_list_empty), inner) - } else { - LazyColumn(contentPadding = inner) { - items(s.tasks, key = { it.taskId }) { task -> - TaskRow( - task = task, - onToggle = { viewModel.toggleComplete(task) }, - onClick = { onOpenTask(task.taskId) }, - ) - } - } - } + is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, viewModel) } } } +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -private fun TaskRow(task: Task, onToggle: () -> Unit, onClick: () -> Unit) { - ListItem( - modifier = Modifier.clickable(onClick = onClick), - leadingContent = { Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) }, - headlineContent = { - Text(task.title.ifBlank { stringResource(R.string.task_untitled) }) +private fun TaskListBody( + state: TaskListUiState.Content, + filter: TaskFilter, + inner: PaddingValues, + onOpenTask: (Long) -> Unit, + viewModel: TaskListViewModel, +) { + val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) } + // A parent represents its children via a progress chip, so drop children + // from the flat list — unless their parent isn't in this view (can happen in + // smart lists), where we keep them standalone so nothing silently vanishes. + val childrenByParent = remember(state.tasks) { + state.tasks.filter { (it.parentId ?: 0L) > 0L }.groupBy { it.parentId!! } + } + val displayTasks = remember(state.tasks) { + val present = state.tasks.mapTo(HashSet()) { it.taskId } + state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present } + } + val sections = remember(displayTasks) { TaskSections.of(displayTasks, todayStart, todayEnd) } + val showHeaders = sections.size > 1 + val listId = (filter as? TaskFilter.OfList)?.listId + // In a mixed view (a smart list) the list colour bar + name disambiguate + // rows; inside a single real list both are redundant. + 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) } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = inner.calculateTopPadding() + 8.dp, + bottom = inner.calculateBottomPadding() + 96.dp, + ), + ) { + if (listId != null) { + item(key = "inline-add") { + InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) }) + } + } + + if (state.tasks.isEmpty()) { + item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) } + } + + sections.forEach { section -> + val collapsible = showHeaders && section.section == TaskSection.COMPLETED + if (showHeaders) { + stickyHeader(key = "hdr-${section.section}") { + if (collapsible) { + CollapsibleSectionHeader( + label = sectionLabel(section.section), + count = section.tasks.size, + expanded = completedExpanded, + onToggle = { completedExpanded = !completedExpanded }, + ) + } else { + SectionHeader(sectionLabel(section.section)) + } + } + } + if (!collapsible || completedExpanded) { + itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task -> + val children = childrenByParent[task.taskId] + TaskRow( + task = task, + position = positionOf(index, section.tasks.size), + showListName = showListName, + subtaskDone = children?.count { it.isCompleted } ?: 0, + subtaskTotal = children?.size ?: 0, + onToggle = { viewModel.toggleComplete(task) }, + onDelete = { viewModel.delete(task) }, + onClick = { onOpenTask(task.taskId) }, + ) + } + } + } + } +} + +/** Inline "add a task" — title only, into the current list. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InlineAdd(onAdd: (String) -> Unit) { + var text by remember { mutableStateOf("") } + fun submit() { + if (text.isNotBlank()) { + onAdd(text.trim()) + text = "" + } + } + Surface( + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) { + TextField( + value = text, + onValueChange = { text = it }, + placeholder = { Text(stringResource(R.string.add_task_hint)) }, + leadingIcon = { Icon(Icons.Rounded.Add, contentDescription = null) }, + trailingIcon = { + if (text.isNotBlank()) { + IconButton(onClick = ::submit) { + Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task)) + } + } + }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { submit() }), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** + * One task row: a tonal grouped-card surface (corners from [position], morphing + * rounder on press — the app's shape family) wrapped in a swipe box. Swiping + * right toggles complete (snaps back); swiping left deletes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TaskRow( + task: Task, + position: Position, + showListName: Boolean, + subtaskDone: Int, + subtaskTotal: Int, + onToggle: () -> Unit, + onDelete: () -> Unit, + onClick: () -> Unit, +) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + when (value) { + SwipeToDismissBoxValue.StartToEnd -> { onToggle(); false } // toggle, snap back + SwipeToDismissBoxValue.EndToStart -> { onDelete(); true } // delete, dismiss + SwipeToDismissBoxValue.Settled -> false + } }, - supportingContent = task.description?.takeIf { it.isNotBlank() }?.let { { Text(it) } }, ) + val gap = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> Modifier + } + + SwipeToDismissBox( + state = dismissState, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), + backgroundContent = { SwipeBackground(dismissState.targetValue, task.isCompleted) }, + ) { + TaskRowContent(task, position, showListName, subtaskDone, subtaskTotal, onToggle, onClick) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TaskRowContent( + task: Task, + position: Position, + showListName: Boolean, + subtaskDone: Int, + subtaskTotal: Int, + 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 = "fullCorner") + val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner") + 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 + + Surface( + onClick = onClick, + shape = cardShape(position, full, small), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + interactionSource = interaction, + modifier = Modifier.fillMaxWidth(), + ) { + // Hand-laid row (not ListItem) so a one- vs two-line body never trips + // ListItem's three-line heuristic and leaves dead space at the bottom. + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Calendula's signature accent: a slim pastelised colour bar for the + // list, paired here with the task's completion checkbox. + Box( + modifier = Modifier + .size(width = 5.dp, height = 32.dp) + .clip(RoundedCornerShape(3.dp)) + .background(pastelize(task.effectiveColor, dark)), + ) + Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) + Spacer(Modifier.width(4.dp)) + Column(modifier = Modifier.weight(1f)) { + 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 + }, + ) + if (hasSupporting) { + Spacer(Modifier.height(3.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (due != null) { + Text( + text = due, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (listName != null) ListNameChip(listName, task.effectiveColor) + if (subtaskTotal > 0) SubtaskCountChip(subtaskDone, subtaskTotal) + } + } + } + priorityFlag(task.priority)?.let { flag -> + Spacer(Modifier.width(8.dp)) + flag() + } + } + } +} + +/** 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) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(50), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ListAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + Text( + text = "$done / $total", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** Shared priority colour so the flag reads the same in the list and the detail view. */ +@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 +} + +/** The colored reveal behind a swiping row: complete on the left, delete on the right. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SwipeBackground(target: SwipeToDismissBoxValue, isCompleted: Boolean) { + val (color, icon, desc, alignment) = when (target) { + SwipeToDismissBoxValue.StartToEnd -> SwipeStyle( + MaterialTheme.colorScheme.primaryContainer, + Icons.Rounded.Check, + if (isCompleted) R.string.cd_reopen else R.string.cd_complete, + Alignment.CenterStart, + ) + SwipeToDismissBoxValue.EndToStart -> SwipeStyle( + MaterialTheme.colorScheme.errorContainer, + Icons.Rounded.Delete, + R.string.delete, + Alignment.CenterEnd, + ) + SwipeToDismissBoxValue.Settled -> SwipeStyle( + Color.Transparent, Icons.Rounded.Check, R.string.cd_complete, Alignment.CenterStart, + ) + } + Surface(color = color, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), contentAlignment = alignment) { + Icon(icon, contentDescription = stringResource(desc)) + } + } +} + +private data class SwipeStyle( + val color: Color, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val descRes: Int, + val alignment: Alignment, +) + +private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, small: androidx.compose.ui.unit.Dp): Shape = + when (position) { + Position.Alone -> RoundedCornerShape(full) + Position.Top -> RoundedCornerShape(topStart = full, topEnd = full, bottomStart = small, bottomEnd = small) + Position.Middle -> RoundedCornerShape(small) + Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full) + } + +@Composable +private fun SectionHeader(text: String) { + // Opaque surface so rows scroll cleanly under the pinned header. + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), + ) + } +} + +/** A tappable section header with a count and a chevron that rotates on expand. */ +@Composable +private fun CollapsibleSectionHeader( + label: String, + count: Int, + expanded: Boolean, + onToggle: () -> Unit, +) { + val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "chevron") + Surface( + onClick = onToggle, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(start = 28.dp, end = 20.dp, top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.size(8.dp)) + Text( + text = count.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Rounded.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(rotation), + ) + } + } +} + +/** Icon + message for an empty list — the Calendula agenda-empty pattern. */ +@Composable +private fun EmptyState(text: String) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 64.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Rounded.Checklist, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(16.dp)) + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } } @Composable -private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) { +private fun CenteredMessage(text: String, inner: PaddingValues) { Box( - modifier = Modifier.fillMaxSize().padding(inner).padding(24.dp), + modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp), contentAlignment = Alignment.Center, - ) { Text(text) } + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } } @Composable -private fun titleFor(filter: TaskFilter): String = when (filter) { - is TaskFilter.OfList -> stringResource(R.string.tasks_title) +private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) { + is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title) is TaskFilter.Smart -> stringResource( when (filter.list) { SmartList.TODAY -> R.string.smart_today @@ -127,3 +576,24 @@ private fun titleFor(filter: TaskFilter): String = when (filter) { }, ) } + +@Composable +private fun sectionLabel(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 +internal fun priorityLabel(priority: Priority): String = stringResource( + when (priority) { + Priority.NONE -> R.string.priority_none + Priority.LOW -> R.string.priority_low + Priority.MEDIUM -> R.string.priority_medium + Priority.HIGH -> R.string.priority_high + }, +) 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 b6c200d..a70be71 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 @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -23,7 +24,13 @@ import javax.inject.Inject sealed interface TaskListUiState { data object Loading : TaskListUiState data object Failure : TaskListUiState - data class Content(val tasks: List) : TaskListUiState + + /** + * [listName] is the real list's name when the filter is a + * [TaskFilter.OfList] (for the top-bar title), `null` for smart lists — + * the screen falls back to the smart label in that case. + */ + data class Content(val tasks: List, val listName: String? = null) : TaskListUiState } /** @@ -42,8 +49,16 @@ class TaskListViewModel @Inject constructor( val state: StateFlow = filter.filterNotNull() .flatMapLatest { f -> - repository.tasks(f) - .map, TaskListUiState> { TaskListUiState.Content(it) } + val tasks = repository.tasks(f) + val content: kotlinx.coroutines.flow.Flow = when (f) { + is TaskFilter.OfList -> + combine(tasks, repository.taskLists()) { list, lists -> + TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name) + } + is TaskFilter.Smart -> + tasks.map { TaskListUiState.Content(it) } + } + content .onStart { emit(TaskListUiState.Loading) } .catch { emit(TaskListUiState.Failure) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c31abf..cb1da08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,61 @@ Description Could not save. Try again. + + Add a task + Add task + Complete + Reopen + + + Overdue + Today + Upcoming + No date + Completed + + + None + Low + Medium + High + + + Mark complete + Mark not complete + List + Due + Starts + Priority + Progress + Subtasks + %1$d%% + + + List + Starts + Due + All day + Priority + Reminder + Set + Clear + + + None + At time of task + 5 minutes before + 10 minutes before + 30 minutes before + 1 hour before + 1 day before + + + Enter a title. + Choose a list. + Due can\'t be before the start. + Set a due date to use a reminder. + Lists New task diff --git a/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskSectionsTest.kt b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskSectionsTest.kt new file mode 100644 index 0000000..3f4a032 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/floret/domain/TaskSectionsTest.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.floret.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class TaskSectionsTest { + + private val dayMs = 86_400_000L + private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs) + private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs) + + private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis) + private fun sectionOf(task: Task) = TaskSections.sectionOf(task, todayStart, todayEnd) + + @Test + fun `buckets by due relative to today`() { + assertThat(sectionOf(testTask(due = at(1000 * dayMs - 1)))).isEqualTo(TaskSection.OVERDUE) + assertThat(sectionOf(testTask(due = at(1000 * dayMs + 1)))).isEqualTo(TaskSection.TODAY) + assertThat(sectionOf(testTask(due = at(1001 * dayMs + 1)))).isEqualTo(TaskSection.UPCOMING) + assertThat(sectionOf(testTask(due = null))).isEqualTo(TaskSection.NO_DATE) + } + + @Test + fun `completed always lands in completed regardless of due`() { + val overdueButDone = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs - 1)) + assertThat(sectionOf(overdueButDone)).isEqualTo(TaskSection.COMPLETED) + } + + @Test + fun `of returns only non-empty sections in fixed order`() { + val tasks = listOf( + testTask(id = 1, due = at(1001 * dayMs + 1)), // upcoming + testTask(id = 2, due = at(1000 * dayMs - 1)), // overdue + testTask(id = 3, status = TaskStatus.COMPLETED), // completed + ) + val sections = TaskSections.of(tasks, todayStart, todayEnd) + assertThat(sections.map { it.section }) + .containsExactly(TaskSection.OVERDUE, TaskSection.UPCOMING, TaskSection.COMPLETED) + .inOrder() + } + + @Test + fun `empty input yields no sections`() { + assertThat(TaskSections.of(emptyList(), todayStart, todayEnd)).isEmpty() + } +}