feat(tasks): вложенные задачи (подзадачи) + per-task меню «⋯»
Паритет веб-версии Задач: - CalDAV: RELATED-TO;RELTYPE=PARENT в модели/парсере/билдере VTODO (DavTask.parentUid); createTask принимает parentUid; updateTask и toggleComplete сохраняют связь с родителем. - TasksRepository/VM: createSubtask(parent), deleteTask(task) для любой задачи (не только открытой детали). - UI: у задачи меню «⋯» → «Добавить вложенную задачу» / «Удалить задачу»; подзадачи рисуются под родителем с отступом 24dp; диалог ввода названия подзадачи. Подзадачи исключены из верхнего уровня (не дублируются). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -345,7 +346,10 @@ fun TasksRow(
|
||||
onToggleComplete: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onAddSubtask: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val hasMeta = showMeta && (task.listName.isNotBlank() || task.percentComplete in 1..99 || task.due.isNotBlank())
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -401,6 +405,43 @@ fun TasksRow(
|
||||
color = task.dueDateColor(),
|
||||
)
|
||||
}
|
||||
// Меню «⋯»: вложенная задача / удалить (как в веб-версии)
|
||||
if (onAddSubtask != null || onDelete != null) {
|
||||
Box {
|
||||
Text(
|
||||
"⋯",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = F7Colors.TextSecondary,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { menuOpen = true },
|
||||
),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
androidx.compose.material3.DropdownMenu(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
containerColor = androidx.compose.ui.graphics.Color.White,
|
||||
) {
|
||||
onAddSubtask?.let { add ->
|
||||
androidx.compose.material3.DropdownMenuItem(
|
||||
text = { Text("Добавить вложенную задачу", color = F7Colors.TextPrimary) },
|
||||
onClick = { menuOpen = false; add() },
|
||||
)
|
||||
}
|
||||
onDelete?.let { del ->
|
||||
androidx.compose.material3.DropdownMenuItem(
|
||||
text = { Text("Удалить задачу", color = F7Colors.Error) },
|
||||
onClick = { menuOpen = false; del() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasMeta) {
|
||||
@@ -1064,3 +1105,42 @@ private fun SmartListSettingRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Диалог создания вложенной задачи (подзадачи) — по кнопке «Добавить вложенную задачу». */
|
||||
@Composable
|
||||
fun TaskSubtaskDialog(
|
||||
parentTitle: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Вложенная задача",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Text(
|
||||
"к «$parentTitle»",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
F7OutlinedField(value = text, onValueChange = { text = it }, label = "Название")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||
F7PrimaryButton(
|
||||
text = "Создать",
|
||||
onClick = { onConfirm(text.trim()) },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ data class TaskItem(
|
||||
val description: String,
|
||||
val listHref: String,
|
||||
val listName: String,
|
||||
val parentUid: String = "",
|
||||
) {
|
||||
val isCompleted: Boolean
|
||||
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
|
||||
@@ -46,6 +47,7 @@ data class TaskItem(
|
||||
description = description,
|
||||
calendarName = listName,
|
||||
calendarHref = listHref,
|
||||
parentUid = parentUid,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,6 +82,7 @@ class TasksRepository {
|
||||
dueDate: LocalDate? = null,
|
||||
priority: Int = 0,
|
||||
description: String = "",
|
||||
parentUid: String = "",
|
||||
): TaskItem {
|
||||
val client = authedClient(session)
|
||||
val calendar = calendarForHref(session, listHref)
|
||||
@@ -90,6 +93,7 @@ class TasksRepository {
|
||||
dueDate = dueDate,
|
||||
priority = priority,
|
||||
description = description,
|
||||
parentUid = parentUid,
|
||||
).toTaskItem()
|
||||
}
|
||||
|
||||
@@ -157,6 +161,7 @@ class TasksRepository {
|
||||
description = description,
|
||||
listHref = calendarHref,
|
||||
listName = calendarName,
|
||||
parentUid = parentUid,
|
||||
)
|
||||
|
||||
private fun calendarSlug(href: String): String {
|
||||
|
||||
@@ -17,6 +17,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.dp
|
||||
@@ -37,6 +40,8 @@ fun TasksScreen(
|
||||
) {
|
||||
val vm: TasksViewModel = viewModel()
|
||||
val state by vm.state.collectAsState()
|
||||
// Родитель для создаваемой подзадачи (открывает диалог ввода названия).
|
||||
var subtaskParent by remember { mutableStateOf<TaskItem?>(null) }
|
||||
|
||||
// Стрелка «назад» нижней панели на Задачах → показать панель списков (закрыть открытый список).
|
||||
LaunchedEffect(showListsRequest) {
|
||||
@@ -109,6 +114,8 @@ fun TasksScreen(
|
||||
onReload = { vm.refreshTasks(session) },
|
||||
onToggleCompletedExpanded = vm::toggleCompletedExpanded,
|
||||
onDeleteCompleted = { vm.deleteCompletedTasks(session) },
|
||||
onAddSubtask = { subtaskParent = it },
|
||||
onDeleteTask = { vm.deleteTask(session, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +156,18 @@ fun TasksScreen(
|
||||
onDismiss = vm::closeSettings,
|
||||
onSwipeBack = vm::closeSettings,
|
||||
)
|
||||
|
||||
// Диалог «Добавить вложенную задачу»
|
||||
subtaskParent?.let { parent ->
|
||||
TaskSubtaskDialog(
|
||||
parentTitle = parent.summary,
|
||||
onConfirm = { title ->
|
||||
vm.createSubtask(session, parent, title)
|
||||
subtaskParent = null
|
||||
},
|
||||
onDismiss = { subtaskParent = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -168,7 +187,15 @@ private fun TasksMainContent(
|
||||
onReload: () -> Unit,
|
||||
onToggleCompletedExpanded: () -> Unit,
|
||||
onDeleteCompleted: () -> Unit,
|
||||
onAddSubtask: (TaskItem) -> Unit,
|
||||
onDeleteTask: (TaskItem) -> Unit,
|
||||
) {
|
||||
// Подзадачи (RELATED-TO) — рисуем под родителем с отступом; из верхнего уровня исключаем.
|
||||
val childrenByParent = remember(state.visibleActiveGroups) {
|
||||
state.visibleActiveGroups.flatMap { it.tasks }
|
||||
.filter { it.parentUid.isNotBlank() }
|
||||
.groupBy { it.parentUid }
|
||||
}
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TasksToolbar(
|
||||
serverUrl = session.serverUrl,
|
||||
@@ -218,13 +245,27 @@ private fun TasksMainContent(
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(group.tasks, key = { it.uid }) { task ->
|
||||
TasksRow(
|
||||
task = task,
|
||||
serverUrl = session.serverUrl,
|
||||
onToggleComplete = { onToggleComplete(task) },
|
||||
onOpen = { onOpenTask(task) },
|
||||
)
|
||||
items(group.tasks.filter { it.parentUid.isBlank() }, key = { it.uid }) { task ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TasksRow(
|
||||
task = task,
|
||||
serverUrl = session.serverUrl,
|
||||
onToggleComplete = { onToggleComplete(task) },
|
||||
onOpen = { onOpenTask(task) },
|
||||
onAddSubtask = { onAddSubtask(task) },
|
||||
onDelete = { onDeleteTask(task) },
|
||||
)
|
||||
childrenByParent[task.uid].orEmpty().forEach { sub ->
|
||||
TasksRow(
|
||||
task = sub,
|
||||
serverUrl = session.serverUrl,
|
||||
onToggleComplete = { onToggleComplete(sub) },
|
||||
onOpen = { onOpenTask(sub) },
|
||||
onDelete = { onDeleteTask(sub) },
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,6 +197,7 @@ class TasksViewModel(
|
||||
summary: String,
|
||||
dueInput: String,
|
||||
priority: Int,
|
||||
parentUid: String = "",
|
||||
) {
|
||||
val listHref = _state.value.selectedListHref ?: return
|
||||
if (summary.isBlank()) {
|
||||
@@ -218,6 +219,7 @@ class TasksViewModel(
|
||||
summary = summary.trim(),
|
||||
dueDate = dueDate,
|
||||
priority = priority,
|
||||
parentUid = parentUid,
|
||||
)
|
||||
}.onSuccess {
|
||||
_state.update {
|
||||
@@ -301,6 +303,30 @@ class TasksViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Удаление конкретной задачи из списка (per-task меню «⋯»), не только открытой детали. */
|
||||
fun deleteTask(session: AuthSession, task: TaskItem) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.update { it.copy(saving = true, error = null) }
|
||||
runCatching { repository.deleteTask(session, task) }
|
||||
.onSuccess {
|
||||
_state.update {
|
||||
it.copy(saving = false, detailTask = it.detailTask?.takeIf { d -> d.uid != task.uid })
|
||||
}
|
||||
_state.value.selectedListHref?.let { loadTasks(session, it) }
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(saving = false, error = t.message ?: "Не удалось удалить", unauthorized = t is UnauthorizedException)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Создать подзадачу к [parent] с заголовком [summary]. */
|
||||
fun createSubtask(session: AuthSession, parent: TaskItem, summary: String) {
|
||||
createTask(session, summary, "", 0, parentUid = parent.uid)
|
||||
}
|
||||
|
||||
fun deleteDetail(session: AuthSession) {
|
||||
val task = _state.value.detailTask ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
|
||||
Reference in New Issue
Block a user