Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25cfbe9bdc | |||
| c9340ab2ab | |||
| cdb2471445 | |||
| a5ebcd4d80 |
+2
-2
@@ -39,8 +39,8 @@ android {
|
||||
applicationId 'ru.forbion.f7cloud.mobile'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode 138
|
||||
versionName '0.5.130'
|
||||
versionCode 140
|
||||
versionName '0.5.132'
|
||||
missingDimensionStrategy 'default', 'f7'
|
||||
multiDexEnabled true
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ fun AppScaffold(
|
||||
var filesUploadRequest by remember { mutableIntStateOf(0) }
|
||||
var contactsCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var tasksCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var tasksShowListsRequest by remember { mutableIntStateOf(0) }
|
||||
var supportCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var calendarSettingsRequest by remember { mutableIntStateOf(0) }
|
||||
var talkChatsRequest by remember { mutableIntStateOf(0) }
|
||||
@@ -458,7 +459,10 @@ fun AppScaffold(
|
||||
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
|
||||
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
|
||||
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
|
||||
// Разделы без сайдбара (Карточки/Конференции/Контакты/Задачи/
|
||||
// Задачи: стрелка открывает панель списков (закрывает
|
||||
// открытый список) — как сайдбар в Почте/Файлах.
|
||||
AppTab.Tasks -> tasksShowListsRequest++
|
||||
// Разделы без сайдбара (Карточки/Конференции/Контакты/
|
||||
// Поддержка): стрелка = «назад» к предыдущему разделу.
|
||||
else -> popTabHistory()?.let { activeTab = it }
|
||||
}
|
||||
@@ -566,6 +570,7 @@ fun AppScaffold(
|
||||
modifier = contentModifier,
|
||||
createRequest = tasksCreateRequest,
|
||||
openListSlug = pendingTasksListSlug,
|
||||
showListsRequest = tasksShowListsRequest,
|
||||
onOpenListConsumed = { pendingTasksListSlug = null },
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -31,11 +34,19 @@ fun TasksScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
createRequest: Int = 0,
|
||||
openListSlug: String? = null,
|
||||
showListsRequest: Int = 0,
|
||||
onOpenListConsumed: () -> Unit = {},
|
||||
onUnauthorized: () -> Unit = {},
|
||||
) {
|
||||
val vm: TasksViewModel = viewModel()
|
||||
val state by vm.state.collectAsState()
|
||||
// Родитель для создаваемой подзадачи (открывает диалог ввода названия).
|
||||
var subtaskParent by remember { mutableStateOf<TaskItem?>(null) }
|
||||
|
||||
// Стрелка «назад» нижней панели на Задачах → показать панель списков (закрыть открытый список).
|
||||
LaunchedEffect(showListsRequest) {
|
||||
if (showListsRequest > 0) vm.closeList()
|
||||
}
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
|
||||
vm.load(session)
|
||||
@@ -103,6 +114,8 @@ fun TasksScreen(
|
||||
onReload = { vm.refreshTasks(session) },
|
||||
onToggleCompletedExpanded = vm::toggleCompletedExpanded,
|
||||
onDeleteCompleted = { vm.deleteCompletedTasks(session) },
|
||||
onAddSubtask = { subtaskParent = it },
|
||||
onDeleteTask = { vm.deleteTask(session, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -143,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)
|
||||
@@ -162,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,
|
||||
@@ -212,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