Compare commits

..

3 Commits

Author SHA1 Message Date
b266653e4e chore: bump floret-kit submodule to latest (component superset)
All checks were successful
CI / ci (push) Successful in 4m0s
Adopt floret-kit main @ 566caf4, picking up the grown GroupedRow / OptionPicker / CollapsingScaffold family superset. App compiles and unit tests pass against it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:13:21 +02:00
2f5012dc34 tasks: consolidate add affordance into a setting (FAB or bottom bar)
All checks were successful
CI / ci (push) Successful in 4m8s
Removes the doubled add idiom (top inline-add bar + FAB). A new "Bottom quick-add bar" setting (default off) picks one affordance per screen: off keeps the floating New task button everywhere; on shows a quick-add field pinned to the bottom of a real list (smart lists keep the button, as they have no single target list).

The bottom field floats on the brightest container tone with a shadow so it stands off the task cards, rides above the keyboard (ime ∪ navigation-bar insets), and drops focus when the IME hides so no stray cursor lingers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:01:32 +02:00
ccc07e86c7 tasks: quiet single-line row meta, lazy subtasks in smart lists, polish
- Collapse a task row's supporting info (priority, due, subtask progress,
  list name) into one low-emphasis meta line instead of stacked filled
  chips: priority is a tinted flag, the list name carries its list colour,
  and dates are compact (formatDateTimeCompact).
- Expand subtasks in smart lists too, by lazily fetching a parent's full
  child set on demand (TasksRepository.subtasks + the view model's live
  lazyChildren), while per-list views stay query-free.
- animateItem on the rows so (un)expanding a group fades its subtasks
  in/out and slides the rows below — clean whether the children were
  already loaded or arrive a frame later.
- Sit the undo chip beside the FAB at its height rather than overlapping
  or floating above it.

Bumps the floret-kit submodule for formatDateTimeCompact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:47:50 +02:00
10 changed files with 310 additions and 92 deletions

View File

@@ -31,6 +31,12 @@ data class Settings(
val remindersEnabled: Boolean = true, val remindersEnabled: Boolean = true,
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */ /** Whether the inline "add a subtask" row shows on expanded task-list groups. */
val showAddSubtaskRow: Boolean = true, val showAddSubtaskRow: Boolean = true,
/**
* Add affordance for a list: `false` = the floating "New task" button (opens
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
* Smart lists always use the button (they have no single list to add into).
*/
val bottomAddBar: Boolean = false,
/** /**
* Per-list overrides of [reminderLeadMinutes]: a list present in the map * Per-list overrides of [reminderLeadMinutes]: a list present in the map
* overrides the global default (a null value = no reminder); absent = inherit. * overrides the global default (a null value = no reminder); absent = inherit.
@@ -58,6 +64,7 @@ class SettingsPrefs @Inject constructor(
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0, reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
remindersEnabled = p[REMINDERS_ENABLED] ?: true, remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true, showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]), perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty() defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() } .mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
@@ -82,6 +89,8 @@ class SettingsPrefs @Inject constructor(
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show } suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */ /** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p -> suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap() val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
@@ -100,6 +109,7 @@ class SettingsPrefs @Inject constructor(
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes") val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled") val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row") val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")

View File

@@ -19,6 +19,13 @@ enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
interface TasksRepository { interface TasksRepository {
fun taskLists(): Flow<List<TaskList>> fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>> fun tasks(filter: TaskFilter): Flow<List<Task>>
/**
* The direct children of [parentId], re-emitting live like the other flows.
* Lets a smart-list view pull a parent's full subtask set on demand — those
* children are usually filtered out of the list's own (date-based) query.
*/
fun subtasks(parentId: Long): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?> fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long suspend fun createTask(form: TaskForm): Long

View File

@@ -35,6 +35,12 @@ class TasksRepositoryImpl @Inject constructor(
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) } override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
dataSource.subtasks(parentId)
.filter { it.taskId != parentId }
.sortedWith(TaskSorting.DEFAULT)
}
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing { override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task -> dataSource.task(taskId)?.let { task ->
TaskDetail( TaskDetail(

View File

@@ -45,6 +45,27 @@ fun priorityFill(priority: Priority, dark: Boolean): Color {
return Color(android.graphics.Color.HSVToColor(hsv)) return Color(android.graphics.Color.HSVToColor(hsv))
} }
/**
* Foreground tint (icon/text) for a priority level — the same red/amber/green as
* [priorityFill], but kept saturated enough to read as a small glyph directly on
* the surface rather than as a soft fill. Used by the task list's quiet meta line,
* where priority is a tinted flag instead of a filled pill. [Priority.NONE] is
* unspecified (callers render it neutral / omit it).
*/
fun priorityAccent(priority: Priority, dark: Boolean): Color {
val base = when (priority) {
Priority.HIGH -> 0xFFE53935.toInt() // red
Priority.MEDIUM -> 0xFFFB8C00.toInt() // orange-amber (darker than the fill for contrast)
Priority.LOW -> 0xFF43A047.toInt() // green
Priority.NONE -> return Color.Unspecified
}
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(base, hsv)
hsv[1] = if (dark) 0.55f else 0.85f
hsv[2] = if (dark) 0.85f else 0.62f // light on dark surfaces; deep on light ones
return Color(android.graphics.Color.HSVToColor(hsv))
}
/** /**
* A coloured status pill for a task's priority: a flag plus the level's [label], * A coloured status pill for a task's priority: a flag plus the level's [label],
* filled in that level's pastel hue. Used read-only in the list and detail * filled in that level's pastel hue. Used read-only in the list and detail
@@ -91,14 +112,14 @@ fun PriorityChip(
val body: @Composable () -> Unit = { val body: @Composable () -> Unit = {
Row( Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (showIcon) { if (showIcon) {
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp)) Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(14.dp))
} }
Text(label, style = MaterialTheme.typography.labelMedium, color = content) Text(label, style = MaterialTheme.typography.labelSmall, color = content)
} }
} }
val shape = RoundedCornerShape(50) val shape = RoundedCornerShape(50)

View File

@@ -338,7 +338,7 @@ private fun TaskFormScreen(
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_add_subtask_row), title = stringResource(R.string.settings_add_subtask_row),
summary = stringResource(R.string.settings_add_subtask_row_hint), summary = stringResource(R.string.settings_add_subtask_row_hint),
position = Position.Bottom, position = Position.Middle,
trailing = { trailing = {
Switch( Switch(
checked = state.settings.showAddSubtaskRow, checked = state.settings.showAddSubtaskRow,
@@ -347,6 +347,18 @@ private fun TaskFormScreen(
}, },
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) }, onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
) )
GroupedRow(
title = stringResource(R.string.settings_bottom_add_bar),
summary = stringResource(R.string.settings_bottom_add_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.settings.bottomAddBar,
onCheckedChange = viewModel::setBottomAddBar,
)
},
onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) },
)
} }
if (showDefaultList) { if (showDefaultList) {

View File

@@ -44,6 +44,7 @@ class SettingsViewModel @Inject constructor(
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) } fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) } fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) } fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
/** Toggle whether [field] shows by default on a new task's edit form. */ /** Toggle whether [field] shows by default on a new task's edit form. */
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch { fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {

View File

@@ -21,6 +21,12 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist 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.ExpandMore 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
@@ -67,6 +74,7 @@ import kotlinx.coroutines.delay
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.platform.LocalFocusManager
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -88,10 +96,9 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.ListNameChip import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.agendula.ui.common.PriorityChip import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import de.jeanlucmakiola.floret.time.formatDateTime
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import java.time.ZoneId import java.time.ZoneId
@@ -115,7 +122,13 @@ fun TaskListScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listName = (state as? TaskListUiState.Content)?.listName val content = state as? TaskListUiState.Content
val listName = content?.listName
val listId = (filter as? TaskFilter.OfList)?.listId
// One add affordance, never two: a real list with the setting on gets a pinned
// bottom quick-add bar; everything else (incl. smart lists, which have no single
// target list) gets the floating "New task" button.
val showBottomAddBar = content?.bottomAddBar == true && listId != null
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip // Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
// gives an undo window: Undo restores it, otherwise the delete commits. A // gives an undo window: Undo restores it, otherwise the delete commits. A
@@ -150,11 +163,18 @@ fun TaskListScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
ExtendedFloatingActionButton( if (!showBottomAddBar) {
onClick = onNewTask, ExtendedFloatingActionButton(
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, onClick = onNewTask,
text = { Text(stringResource(R.string.new_task)) }, icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
) text = { Text(stringResource(R.string.new_task)) },
)
}
},
bottomBar = {
if (showBottomAddBar) {
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
}
}, },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -163,16 +183,24 @@ fun TaskListScreen(
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel) is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
} }
UndoChip( // A FAB-height band anchored at the bottom-start with the FAB's own
visible = undoTarget != null, // margin; centring the chip in it lines the chip up beside the bottom-end
onUndo = { // FAB at exactly its height, rather than sitting a touch above it.
undoTarget?.let { viewModel.undoDelete(it.taskId) } Box(
undoTarget = null
},
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomStart)
.padding(bottom = inner.calculateBottomPadding() + 24.dp), .padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp)
) .height(56.dp),
contentAlignment = Alignment.CenterStart,
) {
UndoChip(
visible = undoTarget != null,
onUndo = {
undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null
},
)
}
} }
} }
} }
@@ -251,20 +279,34 @@ private fun TaskListBody(
val childrenByParent = remember(state.tasks) { val childrenByParent = remember(state.tasks) {
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! } state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
} }
// In a smart list a parent's subtasks are usually filtered out (they're undated
// or due other days), so we pull them on demand. Report only expanded parents
// whose full child set isn't already in this view; the VM then observes those
// children live. In a real list that set is empty, so no extra queries run.
val tasksById = remember(state.tasks) { state.tasks.associateBy { it.taskId } }
val parentsNeedingChildren = expandedParents.filter { id ->
val parent = tasksById[id]
parent != null && (childrenByParent[id]?.size ?: 0) != parent.subtaskTotal
}.toSet()
LaunchedEffect(parentsNeedingChildren) { viewModel.setLazyChildParents(parentsNeedingChildren) }
val lazyChildren by viewModel.lazyChildren.collectAsStateWithLifecycle()
// A parent's full child set: the rows already in this view when we hold them
// all, otherwise the lazily-loaded set fetched for smart lists.
fun childrenOf(task: Task): List<Task> {
val inList = childrenByParent[task.taskId].orEmpty()
return if (inList.size == task.subtaskTotal) inList else lazyChildren[task.taskId].orEmpty()
}
// When the bottom quick-add bar is shown it already lives in the scaffold's
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
val bottomBarShown = state.bottomAddBar && listId != null
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = inner.calculateTopPadding() + 8.dp, top = inner.calculateTopPadding() + 8.dp,
bottom = inner.calculateBottomPadding() + 96.dp, bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
), ),
) { ) {
if (listId != null) {
item(key = "inline-add") {
InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) })
}
}
if (state.tasks.isEmpty()) { if (state.tasks.isEmpty()) {
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) } item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
} }
@@ -295,19 +337,21 @@ private fun TaskListBody(
val rows = buildList { val rows = buildList {
val topLevel = section.tasks val topLevel = section.tasks
topLevel.forEachIndexed { p, task -> topLevel.forEachIndexed { p, task ->
val children = childrenByParent[task.taskId].orEmpty() // The full child set — already in this view for a real list,
// Only expandable when we hold every child: true in a real // or fetched on demand for a smart list (childrenOf). A parent
// list, but a smart list may omit children due on other // is expandable whenever it has children; until a smart list's
// days, and a half-shown set would mislead. // set has loaded the group simply shows nothing extra.
val canExpand = task.subtaskTotal > 0 && children.size == task.subtaskTotal val children = childrenOf(task)
val canExpand = task.subtaskTotal > 0
val isExpanded = canExpand && task.taskId in expandedParents val isExpanded = canExpand && task.taskId in expandedParents
val hasExpansionContent = children.isNotEmpty() || state.showAddSubtaskRow
// Top-level tasks keep their own connected run — full top // Top-level tasks keep their own connected run — full top
// only on the first, full bottom only on the last — so the // only on the first, full bottom only on the last — so the
// task after an expanded one stays mid-run (small top). An // task after an expanded one stays mid-run (small top). An
// expanded parent's bottom opens (small) to meet its subtasks. // expanded parent's bottom opens (small) to meet its subtasks.
val parentPosition = cornerPosition( val parentPosition = cornerPosition(
topFull = p == 0, topFull = p == 0,
bottomFull = if (isExpanded) false else p == topLevel.lastIndex, bottomFull = if (isExpanded && hasExpansionContent) false else p == topLevel.lastIndex,
) )
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition)) add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
if (isExpanded) { if (isExpanded) {
@@ -331,8 +375,13 @@ private fun TaskListBody(
} }
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r -> itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
val lastInRun = index == rows.lastIndex val lastInRun = index == rows.lastIndex
// animateItem so an (un)expanding group fades its subtasks in/out
// and slides the rows below — clean whether the children were
// already loaded (real list) or arrive a frame later (smart list).
val rowModifier = Modifier.animateItem()
when (val row = r.row) { when (val row = r.row) {
is ListRow.Parent -> TaskRow( is ListRow.Parent -> TaskRow(
modifier = rowModifier,
task = row.task, task = row.task,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
@@ -356,6 +405,7 @@ private fun TaskListBody(
onClick = { onOpenTask(row.task.taskId) }, onClick = { onOpenTask(row.task.taskId) },
) )
is ListRow.Sub -> SubtaskRow( is ListRow.Sub -> SubtaskRow(
modifier = rowModifier,
task = row.task, task = row.task,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
@@ -363,6 +413,7 @@ private fun TaskListBody(
onClick = { onOpenTask(row.task.taskId) }, onClick = { onOpenTask(row.task.taskId) },
) )
is ListRow.AddSub -> AddSubtaskRow( is ListRow.AddSub -> AddSubtaskRow(
modifier = rowModifier,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
onAdd = { viewModel.quickAddSubtask(row.parent, it) }, onAdd = { viewModel.quickAddSubtask(row.parent, it) },
@@ -375,9 +426,9 @@ private fun TaskListBody(
} }
/** Inline "add a task" — title only, into the current list. */ /** Inline "add a task" — title only, into the current list. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
private fun InlineAdd(onAdd: (String) -> Unit) { private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
fun submit() { fun submit() {
if (text.isNotBlank()) { if (text.isNotBlank()) {
@@ -385,10 +436,25 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
text = "" text = ""
} }
} }
// Compose keeps a TextField focused (cursor blinking) even after the keyboard
// is dismissed by a tap outside it — so when the IME hides, drop focus too.
val focusManager = LocalFocusManager.current
val imeVisible = WindowInsets.isImeVisible
LaunchedEffect(imeVisible) {
if (!imeVisible) focusManager.clearFocus()
}
Surface( Surface(
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh, // Floating use (bottom quick-add) steps up to the brightest container tone
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), // — a real step above the surfaceContainerHigh task cards — and lifts off
// the list with a heavier shadow, so it doesn't blend into the rows.
color = if (elevated) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
shadowElevation = if (elevated) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
TextField( TextField(
value = text, value = text,
@@ -416,6 +482,23 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
} }
} }
/**
* The bottom quick-add field: [InlineAdd] pinned as the scaffold's bottomBar, but
* with no bar plate of its own — the rounded field floats on the screen background
* (its own shadow lifting it off the list). Rides above the keyboard and clears the
* navigation bar (ime navigation-bar insets).
*/
@Composable
private fun QuickAddBar(onAdd: (String) -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)),
) {
InlineAdd(onAdd = onAdd, elevated = true)
}
}
/** /**
* One task row: a tonal grouped-card surface (corners from [position], morphing * 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 * rounder on press — the app's shape family) wrapped in a swipe box. Swiping
@@ -435,6 +518,7 @@ private fun TaskRow(
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value -> confirmValueChange = { value ->
@@ -455,7 +539,7 @@ private fun TaskRow(
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, state = dismissState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour + // dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed. // icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
@@ -482,10 +566,11 @@ private fun TaskRowContent(
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner") 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 small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val due = task.due?.formatDateTime(task.isAllDay) val due = task.due?.formatDateTimeCompact(task.isAllDay)
val listName = task.listName?.takeIf { showListName && it.isNotBlank() } val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
val hasSupporting = due != null || listName != null || subtaskTotal > 0 || // Everything secondary collapses into one quiet supporting line below the title.
task.priority != Priority.NONE val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
listName != null
Surface( Surface(
onClick = onClick, onClick = onClick,
@@ -526,29 +611,17 @@ private fun TaskRowContent(
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
}, },
) )
if (hasSupporting) { if (hasMeta) {
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
// Flow so the chips wrap to a further line when due date + list TaskMetaLine(
// + subtask progress can't share one (e.g. a dated task with priority = task.priority,
// subtasks), rather than squeezing the trailing chip. due = due,
FlowRow( subtaskDone = subtaskDone,
verticalArrangement = Arrangement.spacedBy(3.dp), subtaskTotal = subtaskTotal,
horizontalArrangement = Arrangement.spacedBy(8.dp), listName = listName,
itemVerticalAlignment = Alignment.CenterVertically, listColor = task.effectiveColor,
) { dark = dark,
if (task.priority != Priority.NONE) { )
PriorityChip(task.priority, priorityLabel(task.priority))
}
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)
}
} }
} }
// Trailing area is only ever the expand control, so the row's right // Trailing area is only ever the expand control, so the row's right
@@ -561,29 +634,83 @@ private fun TaskRowContent(
} }
} }
/** A small neutral pill showing subtask progress, e.g. "3 / 5". */ /**
* The single low-emphasis supporting line under a task title: priority, due date,
* subtask progress and (in mixed views) the list name, joined by middots. The text
* is quiet `onSurfaceVariant` throughout; the only colour is the priority flag and
* the list name (tinted to its list) — so even a fully-populated row stays calm and
* one line instead of a stack of filled pills.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun SubtaskCountChip(done: Int, total: Int) { private fun TaskMetaLine(
Surface( priority: Priority,
color = MaterialTheme.colorScheme.surfaceContainerHighest, due: String?,
shape = RoundedCornerShape(50), subtaskDone: Int,
subtaskTotal: Int,
listName: String?,
listColor: Int,
dark: Boolean,
) {
val muted = MaterialTheme.colorScheme.onSurfaceVariant
val style = MaterialTheme.typography.bodySmall
val segments = buildList<@Composable () -> Unit> {
if (priority != Priority.NONE) {
add {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
Icons.Rounded.Flag,
contentDescription = null,
tint = priorityAccent(priority, dark),
modifier = Modifier.size(13.dp),
)
Text(priorityLabel(priority), style = style, color = muted)
}
}
}
if (due != null) add { Text(due, style = style, color = muted) }
if (subtaskTotal > 0) {
add {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
Icons.AutoMirrored.Rounded.ListAlt,
contentDescription = null,
tint = muted,
modifier = Modifier.size(13.dp),
)
Text("$subtaskDone / $subtaskTotal", style = style, color = muted)
}
}
}
if (listName != null) {
add {
Text(
listName,
style = style,
color = pastelize(listColor, dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
FlowRow(
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) { ) {
Row( segments.forEachIndexed { i, segment ->
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), // A middot between segments — its own flow item, so the line can still
verticalAlignment = Alignment.CenterVertically, // wrap gracefully when truly everything is set on a narrow screen.
horizontalArrangement = Arrangement.spacedBy(4.dp), if (i > 0) {
) { Text("·", style = style, color = muted, modifier = Modifier.padding(horizontal = 6.dp))
Icon( }
imageVector = Icons.AutoMirrored.Rounded.ListAlt, segment()
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Text(
text = "$done / $total",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
@@ -599,6 +726,7 @@ private fun AddSubtaskRow(
position: Position, position: Position,
lastInRun: Boolean, lastInRun: Boolean,
onAdd: (String) -> Unit, onAdd: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
fun submit() { fun submit() {
@@ -612,7 +740,7 @@ private fun AddSubtaskRow(
shape = cardShape(position, 22.dp, 6.dp), shape = cardShape(position, 22.dp, 6.dp),
// Same tone as the subtask rows it closes, so the group reads as one unit. // Same tone as the subtask rows it closes, so the group reads as one unit.
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
@@ -725,6 +853,7 @@ private fun SubtaskRow(
lastInRun: Boolean, lastInRun: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
@@ -738,7 +867,7 @@ private fun SubtaskRow(
// reads as a distinct, recessive tone — the nesting cue alongside shape. // reads as a distinct, recessive tone — the nesting cue alongside shape.
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
interactionSource = interaction, interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine 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.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -36,6 +37,8 @@ sealed interface TaskListUiState {
val listName: String? = null, val listName: String? = null,
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
val showAddSubtaskRow: Boolean = true, val showAddSubtaskRow: Boolean = true,
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false,
) : TaskListUiState ) : TaskListUiState
} }
@@ -70,13 +73,14 @@ class TaskListViewModel @Inject constructor(
} }
// Drop rows pending an undoable delete so the row vanishes on swipe // Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit, // while the actual provider delete waits for the snackbar to commit,
// and fold in the live "show add-subtask row" setting. // and fold in the live UI settings (add-subtask row, bottom add bar).
val showAddSub = settingsPrefs.settings.map { it.showAddSubtaskRow } val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar }
combine(content, pendingDeletes, showAddSub) { st, pending, showRow -> combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) ->
if (st is TaskListUiState.Content) { if (st is TaskListUiState.Content) {
st.copy( st.copy(
tasks = st.tasks.filter { it.taskId !in pending }, tasks = st.tasks.filter { it.taskId !in pending },
showAddSubtaskRow = showRow, showAddSubtaskRow = showRow,
bottomAddBar = bottomBar,
) )
} else { } else {
st st
@@ -87,6 +91,32 @@ class TaskListViewModel @Inject constructor(
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
/**
* Parents the screen has expanded whose children it can't pull from the list
* itself — i.e. smart lists, where the date filter drops a parent's subtasks.
* For each we observe the full child set live (below), so the group can expand.
*/
private val lazyChildParents = MutableStateFlow<Set<Long>>(emptySet())
/**
* The on-demand child sets, keyed by parent id, kept live. Empty for real
* lists (the screen reports no parents there, since it already holds every
* child) so no redundant queries run.
*/
val lazyChildren: StateFlow<Map<Long, List<Task>>> =
lazyChildParents
.flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyMap())
} else {
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** The screen reports which expanded parents need their children fetched. */
fun setLazyChildParents(ids: Set<Long>) { lazyChildParents.value = ids }
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch { fun toggleComplete(task: Task) = viewModelScope.launch {

View File

@@ -198,6 +198,8 @@
<string name="settings_default_list_first">First available list</string> <string name="settings_default_list_first">First available list</string>
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string> <string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string> <string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<!-- Reminder lead times (custom amounts) --> <!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes"> <plurals name="reminder_minutes">