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:
b-dev-mobile
2026-07-10 07:32:19 +00:00
parent cdb2471445
commit c9340ab2ab
5 changed files with 175 additions and 7 deletions
@@ -75,6 +75,8 @@ data class DavTask(
val description: String,
val calendarName: String,
val calendarHref: String,
/** UID родительской задачи (RELATED-TO;RELTYPE=PARENT) — для подзадач; пусто у корневых. */
val parentUid: String = "",
) {
val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
@@ -87,6 +89,11 @@ object CalDavClient {
private val icsDue = Pattern.compile("DUE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsStatus = Pattern.compile("STATUS:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsUid = Pattern.compile("UID:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
// RELATED-TO с необязательным RELTYPE=PARENT (или без параметров) → UID родителя.
private val icsRelatedTo = Pattern.compile(
"RELATED-TO(?:;[^:\\r\\n]*)?:([^\\r\\n]+)",
Pattern.CASE_INSENSITIVE,
)
private val icsDtStamp = Pattern.compile("DTSTAMP[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsLastModified = Pattern.compile("LAST-MODIFIED[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsPriority = Pattern.compile("PRIORITY:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
@@ -634,6 +641,7 @@ object CalDavClient {
dueDate: LocalDate? = null,
priority: Int = 0,
description: String = "",
parentUid: String = "",
): DavTask {
val uid = "${UUID.randomUUID()}@f7cloud.mobile"
val ics = buildVTodoIcs(
@@ -644,6 +652,7 @@ object CalDavClient {
priority = priority,
percentComplete = 0,
description = description,
parentUid = parentUid,
)
val url = calendar.href.trimEnd('/') + "/$uid.ics"
val req = Request.Builder()
@@ -670,6 +679,7 @@ object CalDavClient {
description = description,
calendarName = calendar.displayName,
calendarHref = calendar.href,
parentUid = parentUid,
)
}
@@ -691,6 +701,7 @@ object CalDavClient {
priority = priority,
percentComplete = percentComplete,
description = description,
parentUid = task.parentUid, // не терять связь с родителем при правке подзадачи
)
val req = Request.Builder()
.url(task.href)
@@ -938,6 +949,7 @@ object CalDavClient {
description = extractIcs(icsDescription, ics),
calendarName = calendarName,
calendarHref = calendarHref,
parentUid = extractIcs(icsRelatedTo, ics).trim(),
)
}
@@ -949,6 +961,7 @@ object CalDavClient {
priority: Int,
percentComplete: Int,
description: String,
parentUid: String = "",
): String {
val now = Instant.now()
return buildString {
@@ -976,6 +989,9 @@ object CalDavClient {
if (description.isNotBlank()) {
appendLine("DESCRIPTION:${escapeIcsText(description)}")
}
if (parentUid.isNotBlank()) {
appendLine("RELATED-TO;RELTYPE=PARENT:${escapeIcsText(parentUid)}")
}
appendLine("END:VTODO")
appendLine("END:VCALENDAR")
}
@@ -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 ->
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) {