M2: build out task list, detail & edit screens
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Task>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LocalDate?>(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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()}"
|
||||||
@@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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
|
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
|
* One segment of a grouped list: a tonal [Surface] whose corner radii come from
|
||||||
* corner radii come from its [position] (so a run of rows reads as a single
|
* [position] (so a run reads as a single rounded card), with a small gap below
|
||||||
* rounded card). Corners morph further on press — the expressive shape play.
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -54,24 +96,6 @@ fun GroupedRow(
|
|||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
onClick: (() -> 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) {
|
val itemColors = if (selected) {
|
||||||
ListItemDefaults.colors(
|
ListItemDefaults.colors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
@@ -83,7 +107,17 @@ fun GroupedRow(
|
|||||||
} else {
|
} else {
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
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(
|
ListItem(
|
||||||
headlineContent = { Text(title) },
|
headlineContent = { Text(title) },
|
||||||
supportingContent = summary?.let { text -> { Text(text) } },
|
supportingContent = summary?.let { text -> { Text(text) } },
|
||||||
@@ -93,24 +127,4 @@ fun GroupedRow(
|
|||||||
modifier = Modifier.heightIn(min = minHeight),
|
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() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,23 @@ package de.jeanlucmakiola.floret.ui.common
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Checklist
|
import androidx.compose.material.icons.filled.Checklist
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +1,86 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.detail
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.Delete
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.Modifier
|
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.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.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.floret.R
|
import de.jeanlucmakiola.floret.R
|
||||||
|
import de.jeanlucmakiola.floret.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
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,
|
* One task's detail. Each fact is its own tonal card with a leading icon (the
|
||||||
* with edit / delete in the top bar, against the already-built
|
* Calendula detail pattern); only genuinely-grouped things — the subtasks — use
|
||||||
* [TaskDetailViewModel]. M3 fleshes this out with dates, priority,
|
* the grouped-row run. Edit / delete live in the top bar.
|
||||||
* percent-complete and subtask presentation.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -82,46 +130,366 @@ fun TaskDetailScreen(
|
|||||||
TaskDetailUiState.Loading -> Unit
|
TaskDetailUiState.Loading -> Unit
|
||||||
TaskDetailUiState.NotFound ->
|
TaskDetailUiState.NotFound ->
|
||||||
CenteredMessage(stringResource(R.string.task_detail_not_found), inner)
|
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
|
@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 task = detail.task
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val accent = pastelize(task.effectiveColor, dark)
|
||||||
|
val gap = 12.dp
|
||||||
|
var subtasksExpanded by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(inner)
|
.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())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
) {
|
) {
|
||||||
|
// 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(
|
||||||
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
task.description?.takeIf { it.isNotBlank() }?.let {
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(48.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
Text(
|
||||||
text = it,
|
secondary,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
detail.subtasks.forEach { sub ->
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
text = "• " + sub.title.ifBlank { stringResource(R.string.task_untitled) },
|
text = task.listName ?: stringResource(R.string.detail_list),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<Task>,
|
||||||
|
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 = 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, String?>? {
|
||||||
|
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
|
@Composable
|
||||||
private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
|
private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(inner).padding(24.dp),
|
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) { Text(text) }
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.floret.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
import de.jeanlucmakiola.floret.domain.TaskDetail
|
||||||
|
import de.jeanlucmakiola.floret.domain.TaskForm
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -54,4 +55,15 @@ class TaskDetailViewModel @Inject constructor(
|
|||||||
fun delete(task: Task) = viewModelScope.launch {
|
fun delete(task: Task) = viewModelScope.launch {
|
||||||
runCatching { repository.deleteTask(task.taskId) }
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,55 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.edit
|
package de.jeanlucmakiola.floret.ui.edit
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
import androidx.compose.material.icons.rounded.Check
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MenuAnchorType
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
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.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.floret.R
|
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,
|
* Create / edit form. Title, description, list, start / due date-time with an
|
||||||
* against the already-built [TaskEditViewModel]; it flips `saved` for us to pop
|
* all-day toggle, priority and a reminder offset — validated through the M1
|
||||||
* back. M3 layers on date-time pickers, priority, list selection,
|
* [TaskEditViewModel] / [de.jeanlucmakiola.floret.domain.TaskForm]. The VM flips
|
||||||
* percent-complete and per-task reminders.
|
* `saved` once the write lands, which pops us back.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -42,9 +61,7 @@ fun TaskEditScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(state.saved) {
|
LaunchedEffect(state.saved) { if (state.saved) onSaved() }
|
||||||
if (state.saved) onSaved()
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -78,31 +95,190 @@ fun TaskEditScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(inner)
|
.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())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.title,
|
value = state.title,
|
||||||
onValueChange = viewModel::onTitleChange,
|
onValueChange = viewModel::onTitleChange,
|
||||||
label = { Text(stringResource(R.string.field_title)) },
|
label = { Text(stringResource(R.string.field_title)) },
|
||||||
singleLine = true,
|
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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.description,
|
value = state.description,
|
||||||
onValueChange = viewModel::onDescriptionChange,
|
onValueChange = viewModel::onDescriptionChange,
|
||||||
label = { Text(stringResource(R.string.field_description)) },
|
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(
|
||||||
text = stringResource(R.string.edit_save_failed),
|
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<de.jeanlucmakiola.floret.domain.TaskList>,
|
||||||
|
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,
|
color = MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +1,95 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.tasklist
|
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.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.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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.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.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.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.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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.floret.R
|
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.SmartList
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.floret.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
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,
|
* The tasks for one [TaskFilter]: due-date section headers, grouped tonal rows
|
||||||
* and open/new navigation against the already-built [TaskListViewModel]. The
|
* with a leading checkbox, swipe-to-complete (start→end) / swipe-to-delete
|
||||||
* richer M2 UI (swipe-to-complete / -delete, inline add field, smart-list
|
* (end→start), and — for a real list — an inline add field. Renders against the
|
||||||
* section headers) layers on top of this.
|
* M1 [TaskListViewModel]; actions are fire-and-forget and flow back live.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -48,12 +102,14 @@ fun TaskListScreen(
|
|||||||
viewModel: TaskListViewModel = hiltViewModel(),
|
viewModel: TaskListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
val listName = (state as? TaskListUiState.Content)?.listName
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
MediumTopAppBar(
|
||||||
title = { Text(titleFor(filter)) },
|
title = { Text(titleFor(filter, listName)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -62,6 +118,7 @@ fun TaskListScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -75,15 +132,84 @@ fun TaskListScreen(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
|
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
||||||
is TaskListUiState.Content ->
|
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, viewModel)
|
||||||
if (s.tasks.isEmpty()) {
|
}
|
||||||
CenteredMessage(stringResource(R.string.task_list_empty), inner)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
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 {
|
} else {
|
||||||
LazyColumn(contentPadding = inner) {
|
SectionHeader(sectionLabel(section.section))
|
||||||
items(s.tasks, key = { it.taskId }) { task ->
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!collapsible || completedExpanded) {
|
||||||
|
itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task ->
|
||||||
|
val children = childrenByParent[task.taskId]
|
||||||
TaskRow(
|
TaskRow(
|
||||||
task = task,
|
task = task,
|
||||||
|
position = positionOf(index, section.tasks.size),
|
||||||
|
showListName = showListName,
|
||||||
|
subtaskDone = children?.count { it.isCompleted } ?: 0,
|
||||||
|
subtaskTotal = children?.size ?: 0,
|
||||||
onToggle = { viewModel.toggleComplete(task) },
|
onToggle = { viewModel.toggleComplete(task) },
|
||||||
|
onDelete = { viewModel.delete(task) },
|
||||||
onClick = { onOpenTask(task.taskId) },
|
onClick = { onOpenTask(task.taskId) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,31 +217,354 @@ fun TaskListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Inline "add a task" — title only, into the current list. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TaskRow(task: Task, onToggle: () -> Unit, onClick: () -> Unit) {
|
private fun InlineAdd(onAdd: (String) -> Unit) {
|
||||||
ListItem(
|
var text by remember { mutableStateOf("") }
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
fun submit() {
|
||||||
leadingContent = { Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) },
|
if (text.isNotBlank()) {
|
||||||
headlineContent = {
|
onAdd(text.trim())
|
||||||
Text(task.title.ifBlank { stringResource(R.string.task_untitled) })
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
supportingContent = task.description?.takeIf { it.isNotBlank() }?.let { { Text(it) } },
|
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
|
@Composable
|
||||||
private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(inner).padding(24.dp),
|
modifier = Modifier
|
||||||
contentAlignment = Alignment.Center,
|
.size(width = 5.dp, height = 32.dp)
|
||||||
) { Text(text) }
|
.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
|
@Composable
|
||||||
private fun titleFor(filter: TaskFilter): String = when (filter) {
|
private fun SectionHeader(text: String) {
|
||||||
is TaskFilter.OfList -> stringResource(R.string.tasks_title)
|
// 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: PaddingValues) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
|
||||||
|
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
|
||||||
is TaskFilter.Smart -> stringResource(
|
is TaskFilter.Smart -> stringResource(
|
||||||
when (filter.list) {
|
when (filter.list) {
|
||||||
SmartList.TODAY -> R.string.smart_today
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -23,7 +24,13 @@ import javax.inject.Inject
|
|||||||
sealed interface TaskListUiState {
|
sealed interface TaskListUiState {
|
||||||
data object Loading : TaskListUiState
|
data object Loading : TaskListUiState
|
||||||
data object Failure : TaskListUiState
|
data object Failure : TaskListUiState
|
||||||
data class Content(val tasks: List<Task>) : 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<Task>, val listName: String? = null) : TaskListUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,8 +49,16 @@ class TaskListViewModel @Inject constructor(
|
|||||||
val state: StateFlow<TaskListUiState> =
|
val state: StateFlow<TaskListUiState> =
|
||||||
filter.filterNotNull()
|
filter.filterNotNull()
|
||||||
.flatMapLatest { f ->
|
.flatMapLatest { f ->
|
||||||
repository.tasks(f)
|
val tasks = repository.tasks(f)
|
||||||
.map<List<Task>, TaskListUiState> { TaskListUiState.Content(it) }
|
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = 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) }
|
.onStart { emit(TaskListUiState.Loading) }
|
||||||
.catch { emit(TaskListUiState.Failure) }
|
.catch { emit(TaskListUiState.Failure) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,61 @@
|
|||||||
<string name="field_description">Description</string>
|
<string name="field_description">Description</string>
|
||||||
<string name="edit_save_failed">Could not save. Try again.</string>
|
<string name="edit_save_failed">Could not save. Try again.</string>
|
||||||
|
|
||||||
|
<!-- Task list: inline add + swipe -->
|
||||||
|
<string name="add_task_hint">Add a task</string>
|
||||||
|
<string name="cd_add_task">Add task</string>
|
||||||
|
<string name="cd_complete">Complete</string>
|
||||||
|
<string name="cd_reopen">Reopen</string>
|
||||||
|
|
||||||
|
<!-- Sections (due-date buckets) -->
|
||||||
|
<string name="section_overdue">Overdue</string>
|
||||||
|
<string name="section_today">Today</string>
|
||||||
|
<string name="section_upcoming">Upcoming</string>
|
||||||
|
<string name="section_no_date">No date</string>
|
||||||
|
<string name="section_completed">Completed</string>
|
||||||
|
|
||||||
|
<!-- Priority -->
|
||||||
|
<string name="priority_none">None</string>
|
||||||
|
<string name="priority_low">Low</string>
|
||||||
|
<string name="priority_medium">Medium</string>
|
||||||
|
<string name="priority_high">High</string>
|
||||||
|
|
||||||
|
<!-- Task detail -->
|
||||||
|
<string name="detail_mark_complete">Mark complete</string>
|
||||||
|
<string name="detail_mark_incomplete">Mark not complete</string>
|
||||||
|
<string name="detail_list">List</string>
|
||||||
|
<string name="detail_due">Due</string>
|
||||||
|
<string name="detail_start">Starts</string>
|
||||||
|
<string name="detail_priority">Priority</string>
|
||||||
|
<string name="detail_progress">Progress</string>
|
||||||
|
<string name="detail_subtasks">Subtasks</string>
|
||||||
|
<string name="percent_complete">%1$d%%</string>
|
||||||
|
|
||||||
|
<!-- Task edit -->
|
||||||
|
<string name="edit_list_label">List</string>
|
||||||
|
<string name="edit_start_label">Starts</string>
|
||||||
|
<string name="edit_due_label">Due</string>
|
||||||
|
<string name="edit_all_day">All day</string>
|
||||||
|
<string name="edit_priority_label">Priority</string>
|
||||||
|
<string name="edit_reminder_label">Reminder</string>
|
||||||
|
<string name="edit_set">Set</string>
|
||||||
|
<string name="edit_clear">Clear</string>
|
||||||
|
|
||||||
|
<!-- Reminder offsets -->
|
||||||
|
<string name="reminder_none">None</string>
|
||||||
|
<string name="reminder_at_due">At time of task</string>
|
||||||
|
<string name="reminder_5_min">5 minutes before</string>
|
||||||
|
<string name="reminder_10_min">10 minutes before</string>
|
||||||
|
<string name="reminder_30_min">30 minutes before</string>
|
||||||
|
<string name="reminder_1_hour">1 hour before</string>
|
||||||
|
<string name="reminder_1_day">1 day before</string>
|
||||||
|
|
||||||
|
<!-- Form validation -->
|
||||||
|
<string name="error_blank_title">Enter a title.</string>
|
||||||
|
<string name="error_no_list">Choose a list.</string>
|
||||||
|
<string name="error_due_before_start">Due can\'t be before the start.</string>
|
||||||
|
<string name="error_reminder_without_due">Set a due date to use a reminder.</string>
|
||||||
|
|
||||||
<!-- Lists overview -->
|
<!-- Lists overview -->
|
||||||
<string name="lists_header">Lists</string>
|
<string name="lists_header">Lists</string>
|
||||||
<string name="new_task">New task</string>
|
<string name="new_task">New task</string>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user