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:
@@ -0,0 +1,39 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.compose'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'ru.forbion.f7cloud.feature.tasks'
|
||||
compileSdk 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk 26
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation project(':core:network')
|
||||
implementation project(':core:designsystem')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user