From c9340ab2ab3efe771dec9a6bdbbe5cfd96d101a8 Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Fri, 10 Jul 2026 07:32:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=20(=D0=BF=D0=BE=D0=B4=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8)=20+?= =?UTF-8?q?=20per-task=20=D0=BC=D0=B5=D0=BD=D1=8E=20=C2=AB=E2=8B=AF=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Паритет веб-версии Задач: - 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 --- .../f7cloud/core/network/CalDavClient.kt | 16 ++++ .../f7cloud/feature/tasks/TasksComponents.kt | 80 +++++++++++++++++++ .../f7cloud/feature/tasks/TasksRepository.kt | 5 ++ .../f7cloud/feature/tasks/TasksScreen.kt | 55 +++++++++++-- .../f7cloud/feature/tasks/TasksViewModel.kt | 26 ++++++ 5 files changed, 175 insertions(+), 7 deletions(-) diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt index d6e908a..b26d233 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt @@ -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") } diff --git a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksComponents.kt b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksComponents.kt index b1cc113..20174c8 100644 --- a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksComponents.kt +++ b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksComponents.kt @@ -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), + ) + } + } + } + } +} diff --git a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksRepository.kt b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksRepository.kt index 077f333..8ca706a 100644 --- a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksRepository.kt +++ b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksRepository.kt @@ -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 { diff --git a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksScreen.kt b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksScreen.kt index b91ab71..544adda 100644 --- a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksScreen.kt +++ b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksScreen.kt @@ -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(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), + ) + } + } } } diff --git a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksViewModel.kt b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksViewModel.kt index e97f902..b315f73 100644 --- a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksViewModel.kt +++ b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksViewModel.kt @@ -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) {