Compare commits
2 Commits
211450bc46
...
2f5012dc34
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f5012dc34 | |||
| ccc07e86c7 |
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
if (!showBottomAddBar) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = onNewTask,
|
onClick = onNewTask,
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||||
text = { Text(stringResource(R.string.new_task)) },
|
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,19 +183,27 @@ 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)
|
||||||
}
|
}
|
||||||
|
// A FAB-height band anchored at the bottom-start with the FAB's own
|
||||||
|
// margin; centring the chip in it lines the chip up beside the bottom-end
|
||||||
|
// FAB at exactly its height, rather than sitting a touch above it.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp)
|
||||||
|
.height(56.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
UndoChip(
|
UndoChip(
|
||||||
visible = undoTarget != null,
|
visible = undoTarget != null,
|
||||||
onUndo = {
|
onUndo = {
|
||||||
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
||||||
undoTarget = null
|
undoTarget = null
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = inner.calculateBottomPadding() + 24.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
|
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
|
||||||
@@ -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,30 +611,18 @@ 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
|
||||||
// edge stays put whether or not the task has children (priority now
|
// edge stays put whether or not the task has children (priority now
|
||||||
@@ -561,32 +634,86 @@ 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(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
|
Icons.Rounded.Flag,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = priorityAccent(priority, dark),
|
||||||
modifier = Modifier.size(14.dp),
|
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(
|
Text(
|
||||||
text = "$done / $total",
|
listName,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = style,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = pastelize(listColor, dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
FlowRow(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
segments.forEachIndexed { i, segment ->
|
||||||
|
// A middot between segments — its own flow item, so the line can still
|
||||||
|
// wrap gracefully when truly everything is set on a narrow screen.
|
||||||
|
if (i > 0) {
|
||||||
|
Text("·", style = style, color = muted, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
}
|
||||||
|
segment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The inline "add a subtask" row that closes an expanded group — a [BasicTextField]
|
* The inline "add a subtask" row that closes an expanded group — a [BasicTextField]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Submodule floret-kit updated: 86f44805d1...aee26a28f3
Reference in New Issue
Block a user