Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
package ru.forbion.f7cloud.feature.tasks
import androidx.compose.ui.graphics.Color
import ru.forbion.f7cloud.core.designsystem.F7Colors
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
data class TaskGroup(
val title: String,
val accentColor: Color,
val tasks: List<TaskItem>,
)
object TasksGrouping {
private val ruDateFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru-RU"))
fun groupByDue(
tasks: List<TaskItem>,
sortAscending: Boolean,
): List<TaskGroup> {
if (tasks.isEmpty()) return emptyList()
val today = LocalDate.now()
val sorted = if (sortAscending) {
tasks.sortedWith(compareBy({ it.dueLocalDate() ?: LocalDate.MAX }, { it.summary.lowercase() }))
} else {
tasks.sortedWith(compareByDescending<TaskItem> { it.dueLocalDate() ?: LocalDate.MIN }
.thenBy { it.summary.lowercase() })
}
val grouped = linkedMapOf<String, MutableList<TaskItem>>()
val colors = linkedMapOf<String, Color>()
sorted.forEach { task ->
val date = task.dueLocalDate()
val (title, color) = when {
date == null -> "Без срока" to F7Colors.TextSecondary
date == today -> "Сегодня ${date.format(ruDateFormatter)}" to F7Colors.Primary
date == today.minusDays(1) -> "Вчера ${date.format(ruDateFormatter)}" to F7Colors.Error
date.isBefore(today) -> date.format(ruDateFormatter) to F7Colors.Error
else -> date.format(ruDateFormatter) to F7Colors.Primary
}
grouped.getOrPut(title) { mutableListOf() }.add(task)
colors.putIfAbsent(title, color)
}
return grouped.map { (title, items) ->
TaskGroup(title = title, accentColor = colors[title] ?: F7Colors.Primary, tasks = items)
}
}
fun singleListGroup(listName: String, tasks: List<TaskItem>): List<TaskGroup> {
if (tasks.isEmpty()) return emptyList()
return listOf(
TaskGroup(
title = "Deck: $listName",
accentColor = F7Colors.Primary,
tasks = tasks,
),
)
}
}
fun TaskItem.dueLocalDate(): LocalDate? {
if (dueRaw.isBlank()) return null
return runCatching {
LocalDate.parse(dueRaw.take(8), DateTimeFormatter.BASIC_ISO_DATE)
}.getOrNull()
}
fun TaskItem.isOverdue(): Boolean {
val date = dueLocalDate() ?: return false
return !isCompleted && date.isBefore(LocalDate.now())
}
fun TaskItem.dueDateColor(): Color = when {
isCompleted -> F7Colors.TextSecondary
isOverdue() -> F7Colors.Error
due.isNotBlank() -> F7Colors.Primary
else -> F7Colors.TextSecondary
}
@@ -0,0 +1,216 @@
package ru.forbion.f7cloud.feature.tasks
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.CalDavClient
import ru.forbion.f7cloud.core.network.DavCalendar
import ru.forbion.f7cloud.core.network.DavTask
import ru.forbion.f7cloud.core.network.NetworkFactory
import java.net.URLDecoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
data class TaskListItem(
val href: String,
val displayName: String,
val slug: String,
)
data class TaskItem(
val uid: String,
val href: String,
val etag: String,
val summary: String,
val status: String,
val due: String,
val dueRaw: String,
val priority: Int,
val percentComplete: Int,
val description: String,
val listHref: String,
val listName: String,
) {
val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
fun toDavTask(): DavTask = DavTask(
uid = uid,
href = href,
etag = etag,
summary = summary,
status = status,
due = due,
dueRaw = dueRaw,
priority = priority,
percentComplete = percentComplete,
description = description,
calendarName = listName,
calendarHref = listHref,
)
}
class TasksRepository {
fun listTaskLists(session: AuthSession): List<TaskListItem> {
val client = authedClient(session)
val userId = davUserId(session)
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
return CalDavClient.listCalendars(client, base)
.map { cal ->
TaskListItem(
href = cal.href,
displayName = cal.displayName.ifBlank { calendarSlug(cal.href) },
slug = calendarSlug(cal.href),
)
}
.sortedBy { it.displayName.lowercase() }
}
fun loadTasks(session: AuthSession, listHref: String): List<TaskItem> {
val client = authedClient(session)
val calendar = calendarForHref(session, listHref)
return CalDavClient.queryTasks(client, calendar)
.map { it.toTaskItem() }
.sortedWith(taskComparator)
}
fun createTask(
session: AuthSession,
listHref: String,
summary: String,
dueDate: LocalDate? = null,
priority: Int = 0,
description: String = "",
): TaskItem {
val client = authedClient(session)
val calendar = calendarForHref(session, listHref)
return CalDavClient.createTask(
client = client,
calendar = calendar,
summary = summary,
dueDate = dueDate,
priority = priority,
description = description,
).toTaskItem()
}
fun toggleComplete(session: AuthSession, task: TaskItem, completed: Boolean) {
val client = authedClient(session)
CalDavClient.toggleTaskComplete(client, task.toDavTask(), completed)
}
fun updateTask(
session: AuthSession,
task: TaskItem,
summary: String,
dueRaw: String,
priority: Int,
description: String,
) {
val client = authedClient(session)
CalDavClient.updateTask(
client = client,
task = task.toDavTask(),
summary = summary,
dueRaw = dueRaw,
priority = priority,
description = description,
)
}
fun deleteTask(session: AuthSession, task: TaskItem) {
val client = authedClient(session)
CalDavClient.deleteTask(client, task.toDavTask())
}
fun deleteCompletedTasks(session: AuthSession, tasks: List<TaskItem>) {
val client = authedClient(session)
tasks.filter { it.isCompleted }.forEach { task ->
CalDavClient.deleteTask(client, task.toDavTask())
}
}
fun findListBySlug(lists: List<TaskListItem>, slug: String?): TaskListItem? {
if (slug.isNullOrBlank()) return null
return lists.firstOrNull { it.slug.equals(slug, ignoreCase = true) }
?: lists.firstOrNull { it.href.contains(slug, ignoreCase = true) }
}
private fun calendarForHref(session: AuthSession, listHref: String): DavCalendar {
val lists = listTaskLists(session)
val match = lists.firstOrNull { it.href == listHref }
return DavCalendar(
href = listHref,
displayName = match?.displayName ?: calendarSlug(listHref),
)
}
private fun DavTask.toTaskItem() = TaskItem(
uid = uid,
href = href,
etag = etag,
summary = summary,
status = status,
due = due,
dueRaw = dueRaw,
priority = priority,
percentComplete = percentComplete,
description = description,
listHref = calendarHref,
listName = calendarName,
)
private fun calendarSlug(href: String): String {
val path = href.substringAfter("://").substringAfter('/').trimEnd('/')
val decoded = runCatching {
URLDecoder.decode(path, Charsets.UTF_8.name())
}.getOrDefault(path)
return decoded.substringAfterLast('/')
}
private fun davUserId(session: AuthSession): String =
session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
private fun authedClient(session: AuthSession) = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
companion object {
private val taskComparator = compareBy<TaskItem>(
{ it.isCompleted },
{ it.dueRaw.isBlank() },
{ it.dueRaw },
{ it.summary.lowercase() },
)
fun parseDueInput(input: String): String {
val trimmed = input.trim()
if (trimmed.isBlank()) return ""
runCatching {
LocalDate.parse(trimmed, DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}.onSuccess {
return it.format(DateTimeFormatter.BASIC_ISO_DATE)
}
return trimmed
}
fun formatDueForInput(dueRaw: String): String {
if (dueRaw.isBlank()) return ""
return runCatching {
when {
dueRaw.length >= 8 && dueRaw[8] == 'T' -> {
val date = dueRaw.take(8)
LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE)
.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}
dueRaw.length >= 8 -> {
LocalDate.parse(dueRaw.take(8), DateTimeFormatter.BASIC_ISO_DATE)
.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}
else -> dueRaw
}
}.getOrDefault(dueRaw)
}
}
}
@@ -0,0 +1,234 @@
package ru.forbion.f7cloud.feature.tasks
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
@Composable
fun TasksScreen(
session: AuthSession,
modifier: Modifier = Modifier,
createRequest: Int = 0,
openListSlug: String? = null,
onOpenListConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val vm: TasksViewModel = viewModel()
val state by vm.state.collectAsState()
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
vm.load(session)
}
LaunchedEffect(createRequest) {
if (createRequest > 0) {
if (state.selectedListHref == null && state.lists.isNotEmpty()) {
vm.openList(session, state.lists.first().href)
}
vm.expandCreateInput()
}
}
LaunchedEffect(openListSlug, state.lists) {
if (!openListSlug.isNullOrBlank() && state.lists.isNotEmpty()) {
vm.openListBySlug(session, openListSlug)
onOpenListConsumed()
}
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
F7OverlayDismissHandler(
enabled = state.detailTask != null,
onDismiss = vm::closeDetail,
)
F7OverlayDismissHandler(
enabled = state.settingsOpen,
onDismiss = vm::closeSettings,
)
Box(
modifier = modifier
.fillMaxSize()
.background(F7Colors.Background)
.padding(horizontal = 8.dp, vertical = 8.dp),
) {
when {
state.loading && state.lists.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
}
state.selectedListHref == null -> {
TasksListNavView(
serverUrl = session.serverUrl,
lists = state.lists,
selectedHref = null,
onListClick = { vm.openList(session, it.href) },
)
}
else -> {
TasksMainContent(
session = session,
state = state,
onCreateExpand = vm::expandCreateInput,
onCreateCollapse = vm::collapseCreateInput,
onCreateTextChange = vm::setCreateInputText,
onCreateSubmit = { vm.submitCreateInput(session) },
onFilterClick = vm::openSettings,
onSortClick = vm::toggleSortOrder,
onMoreClick = vm::closeList,
onToggleComplete = { vm.toggleComplete(session, it) },
onOpenTask = vm::openDetail,
onReload = { vm.refreshTasks(session) },
onToggleCompletedExpanded = vm::toggleCompletedExpanded,
onDeleteCompleted = { vm.deleteCompletedTasks(session) },
)
}
}
state.error?.let { error ->
Text(
error,
color = F7Colors.Error,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 96.dp),
)
}
}
TaskDetailSheet(
visible = state.detailTask != null,
serverUrl = session.serverUrl,
task = state.detailTask,
saving = state.saving,
onDismiss = vm::closeDetail,
onSwipeBack = vm::closeDetail,
onToggleComplete = {
state.detailTask?.let { vm.toggleComplete(session, it) }
},
onSave = { summary, due, priority, description ->
vm.saveDetail(session, summary, due, priority, description)
},
onDelete = { vm.deleteDetail(session) },
)
TasksSettingsSheet(
visible = state.settingsOpen,
serverUrl = session.serverUrl,
lists = state.lists,
defaultListHref = state.defaultListHref,
onDefaultListChange = vm::setDefaultList,
onDismiss = vm::closeSettings,
onSwipeBack = vm::closeSettings,
)
}
@Composable
private fun TasksMainContent(
session: AuthSession,
state: TasksUiState,
onCreateExpand: () -> Unit,
onCreateCollapse: () -> Unit,
onCreateTextChange: (String) -> Unit,
onCreateSubmit: () -> Unit,
onFilterClick: () -> Unit,
onSortClick: () -> Unit,
onMoreClick: () -> Unit,
onToggleComplete: (TaskItem) -> Unit,
onOpenTask: (TaskItem) -> Unit,
onReload: () -> Unit,
onToggleCompletedExpanded: () -> Unit,
onDeleteCompleted: () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
TasksToolbar(
serverUrl = session.serverUrl,
createExpanded = state.createInputExpanded,
createText = state.createInputText,
saving = state.saving,
onCreateTextChange = onCreateTextChange,
onCreateExpand = onCreateExpand,
onCreateCollapse = onCreateCollapse,
onCreateSubmit = onCreateSubmit,
onFilterClick = onFilterClick,
onSortClick = onSortClick,
onMoreClick = onMoreClick,
)
if (state.loading && state.tasks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
} else if (state.showEmptyState) {
TasksEmptyState(
serverUrl = session.serverUrl,
loading = state.loading,
onReload = onReload,
modifier = Modifier.fillMaxWidth(),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.visibleActiveGroups.forEach { group ->
item(key = "header-${group.title}") {
TasksGroupHeader(
title = group.title,
accentColor = group.accentColor,
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) },
)
}
}
if (state.completedTasks.isNotEmpty()) {
item(key = "completed-section") {
TasksCompletedSection(
serverUrl = session.serverUrl,
tasks = state.completedTasks,
expanded = state.completedExpanded,
listName = state.selectedList?.displayName ?: "",
deleting = state.deletingCompleted,
onToggleExpanded = onToggleCompletedExpanded,
onToggleComplete = onToggleComplete,
onOpen = onOpenTask,
onDeleteCompleted = onDeleteCompleted,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}
}
}
@@ -0,0 +1,371 @@
package ru.forbion.f7cloud.feature.tasks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException
import java.time.LocalDate
data class TasksUiState(
val loading: Boolean = false,
val saving: Boolean = false,
val deletingCompleted: Boolean = false,
val lists: List<TaskListItem> = emptyList(),
val selectedListHref: String? = null,
val defaultListHref: String? = null,
val tasks: List<TaskItem> = emptyList(),
val hideCompleted: Boolean = true,
val completedExpanded: Boolean = false,
val searchQuery: String = "",
val sortAscending: Boolean = true,
val groupByDate: Boolean = true,
val createInputExpanded: Boolean = false,
val createInputText: String = "",
val settingsOpen: Boolean = false,
val error: String? = null,
val unauthorized: Boolean = false,
val detailTask: TaskItem? = null,
) {
val selectedList: TaskListItem?
get() = lists.firstOrNull { it.href == selectedListHref }
private fun matchesSearch(task: TaskItem): Boolean {
val q = searchQuery.trim()
return q.isEmpty() ||
task.summary.contains(q, ignoreCase = true) ||
task.description.contains(q, ignoreCase = true)
}
val activeTasks: List<TaskItem>
get() = tasks.filter { !it.isCompleted && matchesSearch(it) }
val completedTasks: List<TaskItem>
get() = tasks.filter { it.isCompleted && matchesSearch(it) }
val visibleActiveGroups: List<TaskGroup>
get() {
val active = activeTasks
return if (groupByDate) {
TasksGrouping.groupByDue(active, sortAscending)
} else {
val sorted = if (sortAscending) {
active.sortedBy { it.summary.lowercase() }
} else {
active.sortedByDescending { it.summary.lowercase() }
}
TasksGrouping.singleListGroup(selectedList?.displayName ?: "Список", sorted)
}
}
val showEmptyState: Boolean
get() = !loading && activeTasks.isEmpty() && (hideCompleted || completedTasks.isEmpty())
}
class TasksViewModel(
private val repository: TasksRepository = TasksRepository(),
) : ViewModel() {
private val _state = MutableStateFlow(TasksUiState())
val state: StateFlow<TasksUiState> = _state.asStateFlow()
fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(loading = it.lists.isEmpty(), error = null) }
runCatching { repository.listTaskLists(session) }
.onSuccess { lists ->
val selected = _state.value.selectedListHref
?: _state.value.defaultListHref
?: lists.firstOrNull()?.href
_state.update {
it.copy(
loading = false,
lists = lists,
selectedListHref = selected,
defaultListHref = it.defaultListHref ?: lists.firstOrNull()?.href,
)
}
selected?.let { loadTasks(session, it) }
}
.onFailure { t ->
_state.update {
it.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
}
fun openList(session: AuthSession, listHref: String) {
_state.update {
it.copy(
selectedListHref = listHref,
searchQuery = "",
detailTask = null,
createInputExpanded = false,
createInputText = "",
)
}
loadTasks(session, listHref)
}
fun closeList() {
_state.update {
it.copy(
selectedListHref = null,
tasks = emptyList(),
detailTask = null,
createInputExpanded = false,
createInputText = "",
)
}
}
fun openListBySlug(session: AuthSession, slug: String?) {
val list = repository.findListBySlug(_state.value.lists, slug)
if (list != null) {
openList(session, list.href)
}
}
fun setSearchQuery(query: String) {
_state.update { it.copy(searchQuery = query) }
}
fun toggleHideCompleted() {
_state.update { it.copy(hideCompleted = !it.hideCompleted) }
}
fun toggleCompletedExpanded() {
_state.update { it.copy(completedExpanded = !it.completedExpanded) }
}
fun toggleSortOrder() {
_state.update { it.copy(sortAscending = !it.sortAscending) }
}
fun expandCreateInput() {
_state.update { it.copy(createInputExpanded = true, error = null) }
}
fun collapseCreateInput() {
_state.update { it.copy(createInputExpanded = false, createInputText = "") }
}
fun setCreateInputText(text: String) {
_state.update { it.copy(createInputText = text) }
}
fun openSettings() {
_state.update { it.copy(settingsOpen = true) }
}
fun closeSettings() {
_state.update { it.copy(settingsOpen = false) }
}
fun setDefaultList(href: String?) {
_state.update { it.copy(defaultListHref = href) }
}
fun openDetail(task: TaskItem) {
_state.update { it.copy(detailTask = task) }
}
fun closeDetail() {
_state.update { it.copy(detailTask = null) }
}
fun submitCreateInput(session: AuthSession) {
val summary = _state.value.createInputText.trim()
if (summary.isBlank()) {
collapseCreateInput()
return
}
createTask(session, summary, "", 0)
}
fun createTask(
session: AuthSession,
summary: String,
dueInput: String,
priority: Int,
) {
val listHref = _state.value.selectedListHref ?: return
if (summary.isBlank()) {
_state.update { it.copy(error = "Введите название задачи") }
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(saving = true, error = null) }
runCatching {
val dueRaw = TasksRepository.parseDueInput(dueInput)
val dueDate = dueRaw.takeIf { it.length >= 8 }?.let {
runCatching {
LocalDate.parse(it.take(8), java.time.format.DateTimeFormatter.BASIC_ISO_DATE)
}.getOrNull()
}
repository.createTask(
session = session,
listHref = listHref,
summary = summary.trim(),
dueDate = dueDate,
priority = priority,
)
}.onSuccess {
_state.update {
it.copy(
saving = false,
createInputExpanded = false,
createInputText = "",
)
}
loadTasks(session, listHref)
}.onFailure { t ->
_state.update {
it.copy(
saving = false,
error = t.message ?: "Не удалось создать задачу",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
fun toggleComplete(session: AuthSession, task: TaskItem) {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.toggleComplete(session, task, !task.isCompleted)
}.onSuccess {
val listHref = _state.value.selectedListHref
val detail = _state.value.detailTask
if (detail?.uid == task.uid) {
_state.update {
it.copy(detailTask = detail.copy(
status = if (task.isCompleted) "NEEDS-ACTION" else "COMPLETED",
percentComplete = if (task.isCompleted) 0 else 100,
))
}
}
listHref?.let { loadTasks(session, it) }
}.onFailure { t ->
_state.update {
it.copy(
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
}
fun saveDetail(
session: AuthSession,
summary: String,
dueInput: String,
priority: Int,
description: String,
) {
val task = _state.value.detailTask ?: return
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(saving = true, error = null) }
runCatching {
repository.updateTask(
session = session,
task = task,
summary = summary.trim().ifBlank { task.summary },
dueRaw = TasksRepository.parseDueInput(dueInput),
priority = priority,
description = description.trim(),
)
}.onSuccess {
_state.update { it.copy(saving = false, detailTask = null) }
_state.value.selectedListHref?.let { loadTasks(session, it) }
}.onFailure { t ->
_state.update {
it.copy(
saving = false,
error = t.message ?: "Не удалось сохранить",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
fun deleteDetail(session: AuthSession) {
val task = _state.value.detailTask ?: return
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 = null) }
_state.value.selectedListHref?.let { loadTasks(session, it) }
}
.onFailure { t ->
_state.update {
it.copy(
saving = false,
error = t.message ?: "Не удалось удалить",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
fun deleteCompletedTasks(session: AuthSession) {
val listHref = _state.value.selectedListHref ?: return
val completed = _state.value.completedTasks
if (completed.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(deletingCompleted = true, error = null) }
runCatching { repository.deleteCompletedTasks(session, completed) }
.onSuccess {
_state.update { it.copy(deletingCompleted = false, completedExpanded = false) }
loadTasks(session, listHref)
}
.onFailure { t ->
_state.update {
it.copy(
deletingCompleted = false,
error = t.message ?: "Не удалось удалить",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
fun refreshTasks(session: AuthSession) {
_state.value.selectedListHref?.let { loadTasks(session, it) }
?: load(session)
}
private fun loadTasks(session: AuthSession, listHref: String) {
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(loading = _state.value.tasks.isEmpty(), error = null) }
runCatching { repository.loadTasks(session, listHref) }
.onSuccess { tasks ->
_state.update { it.copy(loading = false, tasks = tasks) }
}
.onFailure { t ->
_state.update {
it.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
}
}