2 Commits

Author SHA1 Message Date
b-dev-mobile 25cfbe9bdc chore: версия 0.5.132 (140)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:32:34 +00:00
b-dev-mobile c9340ab2ab 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>
2026-07-10 07:32:19 +00:00
6 changed files with 177 additions and 9 deletions
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile' applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26 minSdk 26
targetSdk 36 targetSdk 36
versionCode 139 versionCode 140
versionName '0.5.131' versionName '0.5.132'
missingDimensionStrategy 'default', 'f7' missingDimensionStrategy 'default', 'f7'
multiDexEnabled true multiDexEnabled true
@@ -75,6 +75,8 @@ data class DavTask(
val description: String, val description: String,
val calendarName: String, val calendarName: String,
val calendarHref: String, val calendarHref: String,
/** UID родительской задачи (RELATED-TO;RELTYPE=PARENT) — для подзадач; пусто у корневых. */
val parentUid: String = "",
) { ) {
val isCompleted: Boolean val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100 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 icsDue = Pattern.compile("DUE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsStatus = Pattern.compile("STATUS:([^\\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) 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 icsDtStamp = Pattern.compile("DTSTAMP[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsLastModified = Pattern.compile("LAST-MODIFIED[^:]*:([^\\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) private val icsPriority = Pattern.compile("PRIORITY:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
@@ -634,6 +641,7 @@ object CalDavClient {
dueDate: LocalDate? = null, dueDate: LocalDate? = null,
priority: Int = 0, priority: Int = 0,
description: String = "", description: String = "",
parentUid: String = "",
): DavTask { ): DavTask {
val uid = "${UUID.randomUUID()}@f7cloud.mobile" val uid = "${UUID.randomUUID()}@f7cloud.mobile"
val ics = buildVTodoIcs( val ics = buildVTodoIcs(
@@ -644,6 +652,7 @@ object CalDavClient {
priority = priority, priority = priority,
percentComplete = 0, percentComplete = 0,
description = description, description = description,
parentUid = parentUid,
) )
val url = calendar.href.trimEnd('/') + "/$uid.ics" val url = calendar.href.trimEnd('/') + "/$uid.ics"
val req = Request.Builder() val req = Request.Builder()
@@ -670,6 +679,7 @@ object CalDavClient {
description = description, description = description,
calendarName = calendar.displayName, calendarName = calendar.displayName,
calendarHref = calendar.href, calendarHref = calendar.href,
parentUid = parentUid,
) )
} }
@@ -691,6 +701,7 @@ object CalDavClient {
priority = priority, priority = priority,
percentComplete = percentComplete, percentComplete = percentComplete,
description = description, description = description,
parentUid = task.parentUid, // не терять связь с родителем при правке подзадачи
) )
val req = Request.Builder() val req = Request.Builder()
.url(task.href) .url(task.href)
@@ -938,6 +949,7 @@ object CalDavClient {
description = extractIcs(icsDescription, ics), description = extractIcs(icsDescription, ics),
calendarName = calendarName, calendarName = calendarName,
calendarHref = calendarHref, calendarHref = calendarHref,
parentUid = extractIcs(icsRelatedTo, ics).trim(),
) )
} }
@@ -949,6 +961,7 @@ object CalDavClient {
priority: Int, priority: Int,
percentComplete: Int, percentComplete: Int,
description: String, description: String,
parentUid: String = "",
): String { ): String {
val now = Instant.now() val now = Instant.now()
return buildString { return buildString {
@@ -976,6 +989,9 @@ object CalDavClient {
if (description.isNotBlank()) { if (description.isNotBlank()) {
appendLine("DESCRIPTION:${escapeIcsText(description)}") appendLine("DESCRIPTION:${escapeIcsText(description)}")
} }
if (parentUid.isNotBlank()) {
appendLine("RELATED-TO;RELTYPE=PARENT:${escapeIcsText(parentUid)}")
}
appendLine("END:VTODO") appendLine("END:VTODO")
appendLine("END:VCALENDAR") appendLine("END:VCALENDAR")
} }
@@ -4,6 +4,7 @@ import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -345,7 +346,10 @@ fun TasksRow(
onToggleComplete: () -> Unit, onToggleComplete: () -> Unit,
onOpen: () -> Unit, onOpen: () -> Unit,
modifier: Modifier = Modifier, 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()) val hasMeta = showMeta && (task.listName.isNotBlank() || task.percentComplete in 1..99 || task.due.isNotBlank())
Column( Column(
modifier = modifier modifier = modifier
@@ -401,6 +405,43 @@ fun TasksRow(
color = task.dueDateColor(), 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) { 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 description: String,
val listHref: String, val listHref: String,
val listName: String, val listName: String,
val parentUid: String = "",
) { ) {
val isCompleted: Boolean val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100 get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
@@ -46,6 +47,7 @@ data class TaskItem(
description = description, description = description,
calendarName = listName, calendarName = listName,
calendarHref = listHref, calendarHref = listHref,
parentUid = parentUid,
) )
} }
@@ -80,6 +82,7 @@ class TasksRepository {
dueDate: LocalDate? = null, dueDate: LocalDate? = null,
priority: Int = 0, priority: Int = 0,
description: String = "", description: String = "",
parentUid: String = "",
): TaskItem { ): TaskItem {
val client = authedClient(session) val client = authedClient(session)
val calendar = calendarForHref(session, listHref) val calendar = calendarForHref(session, listHref)
@@ -90,6 +93,7 @@ class TasksRepository {
dueDate = dueDate, dueDate = dueDate,
priority = priority, priority = priority,
description = description, description = description,
parentUid = parentUid,
).toTaskItem() ).toTaskItem()
} }
@@ -157,6 +161,7 @@ class TasksRepository {
description = description, description = description,
listHref = calendarHref, listHref = calendarHref,
listName = calendarName, listName = calendarName,
parentUid = parentUid,
) )
private fun calendarSlug(href: String): String { private fun calendarSlug(href: String): String {
@@ -17,6 +17,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,6 +40,8 @@ fun TasksScreen(
) { ) {
val vm: TasksViewModel = viewModel() val vm: TasksViewModel = viewModel()
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
// Родитель для создаваемой подзадачи (открывает диалог ввода названия).
var subtaskParent by remember { mutableStateOf<TaskItem?>(null) }
// Стрелка «назад» нижней панели на Задачах → показать панель списков (закрыть открытый список). // Стрелка «назад» нижней панели на Задачах → показать панель списков (закрыть открытый список).
LaunchedEffect(showListsRequest) { LaunchedEffect(showListsRequest) {
@@ -109,6 +114,8 @@ fun TasksScreen(
onReload = { vm.refreshTasks(session) }, onReload = { vm.refreshTasks(session) },
onToggleCompletedExpanded = vm::toggleCompletedExpanded, onToggleCompletedExpanded = vm::toggleCompletedExpanded,
onDeleteCompleted = { vm.deleteCompletedTasks(session) }, onDeleteCompleted = { vm.deleteCompletedTasks(session) },
onAddSubtask = { subtaskParent = it },
onDeleteTask = { vm.deleteTask(session, it) },
) )
} }
} }
@@ -149,6 +156,18 @@ fun TasksScreen(
onDismiss = vm::closeSettings, onDismiss = vm::closeSettings,
onSwipeBack = 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) @OptIn(ExperimentalMaterial3Api::class)
@@ -168,7 +187,15 @@ private fun TasksMainContent(
onReload: () -> Unit, onReload: () -> Unit,
onToggleCompletedExpanded: () -> Unit, onToggleCompletedExpanded: () -> Unit,
onDeleteCompleted: () -> 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()) { Column(modifier = Modifier.fillMaxSize()) {
TasksToolbar( TasksToolbar(
serverUrl = session.serverUrl, serverUrl = session.serverUrl,
@@ -218,13 +245,27 @@ private fun TasksMainContent(
modifier = Modifier.padding(bottom = 4.dp), modifier = Modifier.padding(bottom = 4.dp),
) )
} }
items(group.tasks, key = { it.uid }) { task -> items(group.tasks.filter { it.parentUid.isBlank() }, key = { it.uid }) { task ->
TasksRow( Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
task = task, TasksRow(
serverUrl = session.serverUrl, task = task,
onToggleComplete = { onToggleComplete(task) }, serverUrl = session.serverUrl,
onOpen = { onOpenTask(task) }, 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, summary: String,
dueInput: String, dueInput: String,
priority: Int, priority: Int,
parentUid: String = "",
) { ) {
val listHref = _state.value.selectedListHref ?: return val listHref = _state.value.selectedListHref ?: return
if (summary.isBlank()) { if (summary.isBlank()) {
@@ -218,6 +219,7 @@ class TasksViewModel(
summary = summary.trim(), summary = summary.trim(),
dueDate = dueDate, dueDate = dueDate,
priority = priority, priority = priority,
parentUid = parentUid,
) )
}.onSuccess { }.onSuccess {
_state.update { _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) { fun deleteDetail(session: AuthSession) {
val task = _state.value.detailTask ?: return val task = _state.value.detailTask ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {