feat(deck): полный функционал Карточек — создание/правка/перемещение/метки
Было: только чтение (доски → список названий карточек). Стало — полноценная работа через внутренний Deck API (/api/v1.0, тот же, что у веб-версии; app-password + OCS-APIRequest, CSRF не требуется): - Доска: колонки-стеки со счётчиком; карточки показывают метки-чипы (цвета доски), срок, исполнителей, чекбокс «выполнено». - Карточка-деталь (лист): правка названия/описания, срок через DatePicker (+сброс), переключение done, метки доски тапом, перемещение в другую колонку, архив, удаление. - Создание карточки (+ в колонке) и колонки (+ Колонка в шапке). - Права: если PERMISSION_EDIT=false — всё только для чтения. - Модели расширены (DeckLabel/description/duedate/assignees/stackId); VM перечитывает доску после каждого действия. Поля сверены с Card.php на forbion. Запись на устройстве не тестировалась (в прод Deck не писал) — проверить при ревью. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DeckCardSheet(
|
||||
card: DeckCard,
|
||||
boardLabels: List<DeckLabel>,
|
||||
stacks: List<DeckStack>,
|
||||
canEdit: Boolean,
|
||||
busy: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onToggleDone: () -> Unit,
|
||||
onSave: (title: String, description: String, duedate: String?) -> Unit,
|
||||
onToggleLabel: (DeckLabel) -> Unit,
|
||||
onMove: (targetStackId: Int) -> Unit,
|
||||
onArchive: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var title by remember(card.id) { mutableStateOf(card.title) }
|
||||
var description by remember(card.id) { mutableStateOf(card.description) }
|
||||
// duedate храним как ISO-строку (как отдаёт/принимает Deck), null = без срока
|
||||
var duedate by remember(card.id) { mutableStateOf(card.duedate) }
|
||||
var datePickerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.96f).heightIn(max = 680.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Done + заголовок
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Checkbox(
|
||||
checked = card.done,
|
||||
onCheckedChange = { if (canEdit) onToggleDone() },
|
||||
enabled = canEdit && !busy,
|
||||
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
|
||||
)
|
||||
Text(
|
||||
if (card.done) "Выполнена" else "Не выполнена",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
F7OutlinedField(value = title, onValueChange = { title = it }, label = "Название")
|
||||
F7OutlinedField(value = description, onValueChange = { description = it }, label = "Описание", minLines = 3)
|
||||
|
||||
// Срок
|
||||
Text("Срок", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 36.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(F7Colors.SurfaceMuted)
|
||||
.clickable(enabled = canEdit) { datePickerOpen = true }
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
duedate?.let { formatDeckDue(it) } ?: "Без срока",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (duedate != null) F7Colors.TextPrimary else F7Colors.TextSecondary,
|
||||
)
|
||||
if (duedate != null) {
|
||||
Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })
|
||||
}
|
||||
}
|
||||
|
||||
// Метки доски
|
||||
if (boardLabels.isNotEmpty()) {
|
||||
Text("Метки", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
boardLabels.forEach { label ->
|
||||
val active = card.labels.any { it.id == label.id }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(
|
||||
(parseDeckColor(label.color) ?: F7Colors.Primary)
|
||||
.copy(alpha = if (active) 0.35f else 0.12f),
|
||||
)
|
||||
.border(
|
||||
if (active) 2.dp else 1.dp,
|
||||
parseDeckColor(label.color) ?: F7Colors.Primary,
|
||||
RoundedCornerShape(4.dp),
|
||||
)
|
||||
.clickable(enabled = canEdit && !busy) { onToggleLabel(label) }
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text(label.title, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Переместить в колонку
|
||||
if (canEdit && stacks.size > 1) {
|
||||
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
stacks.forEach { stack ->
|
||||
val current = stack.id == card.stackId
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(if (current) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
|
||||
.clickable(enabled = !current && !busy) { onMove(stack.id) }
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(stack.title, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canEdit) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||
F7PrimaryButton(
|
||||
text = if (busy) "…" else "Сохранить",
|
||||
onClick = { onSave(title, description, duedate) },
|
||||
enabled = !busy && title.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton("В архив", onClick = onArchive, enabled = !busy, modifier = Modifier.weight(1f))
|
||||
F7SecondaryButton("Удалить", onClick = onDelete, enabled = !busy, modifier = Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (datePickerOpen) {
|
||||
val initMillis = duedate?.let { parseDeckDueMillis(it) }
|
||||
?: System.currentTimeMillis()
|
||||
val pickerState = rememberDatePickerState(initialSelectedDateMillis = initMillis)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { datePickerOpen = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pickerState.selectedDateMillis?.let { millis ->
|
||||
// Deck принимает ISO-8601 (в полдень UTC достаточно для «дня»)
|
||||
val instant = Instant.ofEpochMilli(millis)
|
||||
duedate = DateTimeFormatter.ISO_INSTANT.format(instant)
|
||||
}
|
||||
datePickerOpen = false
|
||||
}) { Text("ОК", color = F7Colors.Primary) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { datePickerOpen = false }) { Text("Отмена", color = F7Colors.TextSecondary) }
|
||||
},
|
||||
) { DatePicker(state = pickerState) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeckTextPromptDialog(
|
||||
title: String,
|
||||
label: String,
|
||||
confirmText: 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(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
F7OutlinedField(value = text, onValueChange = { text = it }, label = label)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||
F7PrimaryButton(
|
||||
text = confirmText,
|
||||
onClick = { onConfirm(text) },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
|
||||
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
|
||||
Instant.parse(iso).toEpochMilli()
|
||||
}.getOrElse {
|
||||
runCatching {
|
||||
java.time.OffsetDateTime.parse(iso).toInstant().toEpochMilli()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Отображение срока: «12 мар». */
|
||||
internal fun formatDeckDue(iso: String): String {
|
||||
val millis = parseDeckDueMillis(iso) ?: return iso.take(10)
|
||||
val date = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
return date.format(DateTimeFormatter.ofPattern("d MMM"))
|
||||
}
|
||||
@@ -1,91 +1,219 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
|
||||
/**
|
||||
* Deck через внутренний фронт-API (`/index.php/apps/deck/api/v1.0`) — тот же, что у веб-версии.
|
||||
* Пишущие запросы авторизуются app-password + OCS-APIRequest (CSRF при app-password не требуется).
|
||||
*/
|
||||
class DeckRepository {
|
||||
private fun apiBase(session: AuthSession): String {
|
||||
return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
|
||||
}
|
||||
private fun apiBase(session: AuthSession): String =
|
||||
"${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
|
||||
|
||||
private fun client(session: AuthSession): OkHttpClient =
|
||||
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
|
||||
// --- Чтение ---
|
||||
|
||||
suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val json = getJson(client, "${apiBase(session)}/boards")
|
||||
val array = when (json) {
|
||||
is JSONArray -> json
|
||||
is JSONObject -> JSONArray().put(json)
|
||||
else -> JSONArray()
|
||||
}
|
||||
val json = getJson(client(session), "${apiBase(session)}/boards")
|
||||
val array = json as? JSONArray ?: (json as? JSONObject)?.let { JSONArray().put(it) } ?: JSONArray()
|
||||
val out = mutableListOf<DeckBoard>()
|
||||
for (i in 0 until array.length()) {
|
||||
val board = array.optJSONObject(i) ?: continue
|
||||
val id = board.optInt("id", 0)
|
||||
val title = board.optString("title")
|
||||
if (id > 0 && title.isNotBlank()) {
|
||||
out += DeckBoard(id = id, title = title, color = board.optString("color"))
|
||||
if (id > 0 && !board.optBoolean("archived", false)) {
|
||||
out += DeckBoard(
|
||||
id = id,
|
||||
title = board.optString("title"),
|
||||
color = board.optString("color"),
|
||||
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject
|
||||
val json = getJson(client(session), "${apiBase(session)}/cards/$cardId") as JSONObject
|
||||
val boardId = json.optInt("boardId", json.optInt("board_id", 0))
|
||||
if (boardId <= 0) error("Карточка не найдена")
|
||||
return DeckCardDetail(
|
||||
cardId = cardId,
|
||||
boardId = boardId,
|
||||
title = json.optString("title"),
|
||||
)
|
||||
return DeckCardDetail(cardId = cardId, boardId = boardId, title = json.optString("title"))
|
||||
}
|
||||
|
||||
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks")
|
||||
val stacksArray = when (stacksJson) {
|
||||
is JSONArray -> stacksJson
|
||||
else -> JSONArray()
|
||||
}
|
||||
val c = client(session)
|
||||
val board = getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject
|
||||
val labels = parseLabels(board.optJSONArray("labels"))
|
||||
val canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
|
||||
val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray()
|
||||
val stacks = mutableListOf<DeckStack>()
|
||||
for (i in 0 until stacksArray.length()) {
|
||||
val stack = stacksArray.optJSONObject(i) ?: continue
|
||||
for (i in 0 until stacksJson.length()) {
|
||||
val stack = stacksJson.optJSONObject(i) ?: continue
|
||||
val stackId = stack.optInt("id", 0)
|
||||
val title = stack.optString("title")
|
||||
val cards = mutableListOf<DeckCard>()
|
||||
if (stackId <= 0) continue
|
||||
val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
|
||||
for (c in 0 until cardsArray.length()) {
|
||||
val card = cardsArray.optJSONObject(c) ?: continue
|
||||
val cardTitle = card.optString("title")
|
||||
if (cardTitle.isNotBlank()) {
|
||||
cards += DeckCard(
|
||||
id = card.optInt("id", 0),
|
||||
title = cardTitle,
|
||||
done = card.has("done") && !card.isNull("done"),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (stackId > 0) {
|
||||
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards)
|
||||
val cards = mutableListOf<DeckCard>()
|
||||
for (cIdx in 0 until cardsArray.length()) {
|
||||
cardsArray.optJSONObject(cIdx)?.let { cards += parseCard(it, stackId) }
|
||||
}
|
||||
stacks += DeckStack(
|
||||
id = stackId,
|
||||
boardId = boardId,
|
||||
title = stack.optString("title").ifBlank { "Колонка" },
|
||||
order = stack.optInt("order", i),
|
||||
cards = cards.sortedBy { it.order },
|
||||
)
|
||||
}
|
||||
return DeckBoardDetail(boardId = boardId, stacks = stacks)
|
||||
return DeckBoardDetail(
|
||||
boardId = boardId,
|
||||
title = board.optString("title"),
|
||||
canEdit = canEdit,
|
||||
labels = labels,
|
||||
stacks = stacks.sortedBy { it.order },
|
||||
)
|
||||
}
|
||||
|
||||
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any {
|
||||
val request = Request.Builder().url(url).build()
|
||||
private fun parseCard(card: JSONObject, stackId: Int): DeckCard = DeckCard(
|
||||
id = card.optInt("id", 0),
|
||||
title = card.optString("title"),
|
||||
done = !card.isNull("done") && card.optString("done").isNotBlank(),
|
||||
description = card.optString("description"),
|
||||
duedate = card.optString("duedate").takeIf { it.isNotBlank() && it != "null" },
|
||||
order = card.optInt("order", 0),
|
||||
stackId = stackId,
|
||||
labels = parseLabels(card.optJSONArray("labels")),
|
||||
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
|
||||
)
|
||||
|
||||
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
|
||||
if (array == null) return emptyList()
|
||||
val out = mutableListOf<DeckLabel>()
|
||||
for (i in 0 until array.length()) {
|
||||
val l = array.optJSONObject(i) ?: continue
|
||||
val id = l.optInt("id", 0)
|
||||
if (id > 0) out += DeckLabel(id = id, title = l.optString("title"), color = l.optString("color"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseAssignees(array: JSONArray?): List<String> {
|
||||
if (array == null) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
val a = array.optJSONObject(i) ?: continue
|
||||
val p = a.optJSONObject("participant")
|
||||
val name = p?.optString("displayname")?.takeIf { it.isNotBlank() }
|
||||
?: p?.optString("uid")?.takeIf { it.isNotBlank() }
|
||||
?: a.optString("displayname")
|
||||
if (name.isNotBlank()) out += name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Запись ---
|
||||
|
||||
fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard {
|
||||
val body = JSONObject().put("title", title).put("type", "plain").put("order", 999).put("stackId", stackId)
|
||||
val json = sendJson(session, "POST", "${apiBase(session)}/cards", body) as? JSONObject
|
||||
return json?.let { parseCard(it, stackId) }
|
||||
?: DeckCard(id = 0, title = title, done = false, stackId = stackId)
|
||||
}
|
||||
|
||||
fun updateCard(
|
||||
session: AuthSession,
|
||||
card: DeckCard,
|
||||
title: String,
|
||||
description: String,
|
||||
duedate: String?,
|
||||
) {
|
||||
val body = JSONObject()
|
||||
.put("title", title)
|
||||
.put("type", "plain")
|
||||
.put("owner", session.username)
|
||||
.put("description", description)
|
||||
.put("order", card.order)
|
||||
.put("stackId", card.stackId)
|
||||
.put("duedate", duedate ?: JSONObject.NULL)
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/${card.id}", body)
|
||||
}
|
||||
|
||||
fun setCardDone(session: AuthSession, cardId: Int, done: Boolean) {
|
||||
val path = if (done) "done" else "undone"
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/$path", JSONObject())
|
||||
}
|
||||
|
||||
fun moveCard(session: AuthSession, cardId: Int, targetStackId: Int, order: Int = 0) {
|
||||
val body = JSONObject().put("stackId", targetStackId).put("order", order)
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/reorder", body)
|
||||
}
|
||||
|
||||
fun archiveCard(session: AuthSession, cardId: Int) {
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/archive", JSONObject())
|
||||
}
|
||||
|
||||
fun deleteCard(session: AuthSession, cardId: Int) {
|
||||
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId", null)
|
||||
}
|
||||
|
||||
fun assignLabel(session: AuthSession, cardId: Int, labelId: Int) {
|
||||
sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/label/$labelId", JSONObject())
|
||||
}
|
||||
|
||||
fun removeLabel(session: AuthSession, cardId: Int, labelId: Int) {
|
||||
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null)
|
||||
}
|
||||
|
||||
fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack {
|
||||
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
|
||||
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
|
||||
val id = json?.optInt("id", 0) ?: 0
|
||||
return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList())
|
||||
}
|
||||
|
||||
// --- HTTP ---
|
||||
|
||||
private fun getJson(client: OkHttpClient, url: String): Any {
|
||||
val request = Request.Builder().url(url).header("OCS-APIRequest", "true").build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Deck API HTTP ${response.code}")
|
||||
}
|
||||
if (!response.isSuccessful || response.body == null) error("Deck API HTTP ${response.code}")
|
||||
val body = response.body!!.string().trim()
|
||||
if (body.startsWith("[")) return JSONArray(body)
|
||||
if (body.startsWith("{")) return JSONObject(body)
|
||||
return JSONArray()
|
||||
return when {
|
||||
body.startsWith("[") -> JSONArray(body)
|
||||
body.startsWith("{") -> JSONObject(body)
|
||||
else -> JSONArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val jsonMedia = "application/json".toMediaType()
|
||||
|
||||
private fun sendJson(session: AuthSession, method: String, url: String, body: JSONObject?): Any? {
|
||||
val payload = (body?.toString() ?: "{}").toRequestBody(jsonMedia)
|
||||
val builder = Request.Builder()
|
||||
.url(url)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
when (method) {
|
||||
"POST" -> builder.post(payload)
|
||||
"PUT" -> builder.put(payload)
|
||||
"DELETE" -> if (body != null) builder.delete(payload) else builder.delete()
|
||||
}
|
||||
client(session).newCall(builder.build()).execute().use { response ->
|
||||
if (!response.isSuccessful) error("Deck API HTTP ${response.code}")
|
||||
val text = response.body?.string()?.trim().orEmpty()
|
||||
return when {
|
||||
text.startsWith("{") -> JSONObject(text)
|
||||
text.startsWith("[") -> JSONArray(text)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,11 +222,20 @@ data class DeckBoard(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val color: String,
|
||||
val canEdit: Boolean = true,
|
||||
)
|
||||
|
||||
data class DeckLabel(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val color: String,
|
||||
)
|
||||
|
||||
data class DeckStack(
|
||||
val id: Int,
|
||||
val boardId: Int,
|
||||
val title: String,
|
||||
val order: Int,
|
||||
val cards: List<DeckCard>,
|
||||
)
|
||||
|
||||
@@ -106,10 +243,19 @@ data class DeckCard(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val done: Boolean,
|
||||
val description: String = "",
|
||||
val duedate: String? = null,
|
||||
val order: Int = 0,
|
||||
val stackId: Int = 0,
|
||||
val labels: List<DeckLabel> = emptyList(),
|
||||
val assignees: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class DeckBoardDetail(
|
||||
val boardId: Int,
|
||||
val title: String,
|
||||
val canEdit: Boolean,
|
||||
val labels: List<DeckLabel>,
|
||||
val stacks: List<DeckStack>,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,26 +1,55 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.F7ListCard
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DeckScreen(
|
||||
session: AuthSession,
|
||||
@@ -31,23 +60,18 @@ fun DeckScreen(
|
||||
) {
|
||||
val vm: DeckViewModel = viewModel()
|
||||
val state by vm.state.collectAsState()
|
||||
var addStackOpen by remember { mutableStateOf(false) }
|
||||
var addCardStackId by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username) {
|
||||
vm.load(session)
|
||||
}
|
||||
LaunchedEffect(session.serverUrl, session.username) { vm.load(session) }
|
||||
LaunchedEffect(openCardId) {
|
||||
val cardId = openCardId ?: return@LaunchedEffect
|
||||
vm.openCardById(session, cardId)
|
||||
onOpenCardConsumed()
|
||||
}
|
||||
LaunchedEffect(state.unauthorized) {
|
||||
if (state.unauthorized) onUnauthorized()
|
||||
}
|
||||
LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() }
|
||||
|
||||
F7OverlayDismissHandler(
|
||||
enabled = state.boardDetail != null,
|
||||
onDismiss = vm::closeBoard,
|
||||
)
|
||||
F7OverlayDismissHandler(enabled = state.boardDetail != null, onDismiss = vm::closeBoard)
|
||||
|
||||
F7ModuleScreen(
|
||||
modifier = modifier,
|
||||
@@ -55,7 +79,12 @@ fun DeckScreen(
|
||||
error = state.error,
|
||||
onRefresh = { vm.load(session) },
|
||||
headerActions = {
|
||||
if (state.selectedBoardId != null) {
|
||||
val detail = state.boardDetail
|
||||
if (detail != null) {
|
||||
if (detail.canEdit) {
|
||||
F7SecondaryButton(text = "+ Колонка", onClick = { addStackOpen = true })
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
F7SecondaryButton(text = "Назад", onClick = { vm.closeBoard() })
|
||||
}
|
||||
},
|
||||
@@ -66,31 +95,240 @@ fun DeckScreen(
|
||||
onRefresh = {
|
||||
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) {
|
||||
if (detail != null) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(detail.stacks, key = { it.id }) { stack ->
|
||||
F7ListCard {
|
||||
Text(stack.title, style = MaterialTheme.typography.titleSmall)
|
||||
stack.cards.forEach { card ->
|
||||
val prefix = if (card.done) "✓ " else "• "
|
||||
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
DeckStackSection(
|
||||
stack = stack,
|
||||
canEdit = detail.canEdit,
|
||||
onCardClick = { vm.openCard(it) },
|
||||
onToggleDone = { vm.toggleDone(session, it) },
|
||||
onAddCard = { addCardStackId = stack.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.boards, key = { it.id }) { board ->
|
||||
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
|
||||
Text(board.title, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
DeckBoardRow(board = board, onClick = { vm.openBoard(session, board.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Карточка-деталь
|
||||
state.openedCard?.let { card ->
|
||||
DeckCardSheet(
|
||||
card = card,
|
||||
boardLabels = state.boardDetail?.labels.orEmpty(),
|
||||
stacks = state.boardDetail?.stacks.orEmpty(),
|
||||
canEdit = state.boardDetail?.canEdit ?: false,
|
||||
busy = state.busy,
|
||||
onDismiss = vm::closeCard,
|
||||
onToggleDone = { vm.toggleDone(session, card) },
|
||||
onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) },
|
||||
onToggleLabel = { vm.toggleLabel(session, card, it) },
|
||||
onMove = { vm.moveCard(session, card, it) },
|
||||
onArchive = { vm.archiveCard(session, card) },
|
||||
onDelete = { vm.deleteCard(session, card) },
|
||||
)
|
||||
}
|
||||
|
||||
if (addStackOpen) {
|
||||
DeckTextPromptDialog(
|
||||
title = "Новая колонка",
|
||||
label = "Название колонки",
|
||||
confirmText = "Создать",
|
||||
onConfirm = { vm.createStack(session, it); addStackOpen = false },
|
||||
onDismiss = { addStackOpen = false },
|
||||
)
|
||||
}
|
||||
addCardStackId?.let { stackId ->
|
||||
DeckTextPromptDialog(
|
||||
title = "Новая карточка",
|
||||
label = "Название карточки",
|
||||
confirmText = "Создать",
|
||||
onConfirm = { vm.createCard(session, stackId, it); addCardStackId = null },
|
||||
onDismiss = { addCardStackId = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFFDFDFD))
|
||||
.border(1.dp, Color(0xFFF0F1F4), RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(parseDeckColor(board.color) ?: F7Colors.Primary),
|
||||
)
|
||||
Text(
|
||||
board.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DeckStackSection(
|
||||
stack: DeckStack,
|
||||
canEdit: Boolean,
|
||||
onCardClick: (DeckCard) -> Unit,
|
||||
onToggleDone: (DeckCard) -> Unit,
|
||||
onAddCard: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(F7Colors.Grey2)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stack.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Text(
|
||||
stack.cards.size.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
if (canEdit) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.PrimaryLight)
|
||||
.clickable(onClick = onAddCard),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.cards.forEach { card ->
|
||||
DeckCardRow(card = card, onClick = { onCardClick(card) }, onToggleDone = { onToggleDone(card) })
|
||||
}
|
||||
if (stack.cards.isEmpty()) {
|
||||
Text("Нет карточек", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFFDFDFD))
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (card.labels.isNotEmpty()) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
card.labels.forEach { DeckLabelChip(it) }
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.border(1.5.dp, if (card.done) F7Colors.Primary else F7Colors.Grey1, RoundedCornerShape(4.dp))
|
||||
.background(if (card.done) F7Colors.Primary else Color.Transparent)
|
||||
.clickable(onClick = onToggleDone),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (card.done) Text("✓", color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
card.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val due = card.duedate?.let { formatDeckDue(it) }
|
||||
if (due != null || card.assignees.isNotEmpty()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 28.dp),
|
||||
) {
|
||||
if (due != null) {
|
||||
Text("🗓 $due", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
|
||||
}
|
||||
if (card.assignees.isNotEmpty()) {
|
||||
Text("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeckLabelChip(label: DeckLabel) {
|
||||
val bg = parseDeckColor(label.color) ?: F7Colors.Primary
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(bg.copy(alpha = 0.22f))
|
||||
.border(1.dp, bg, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(
|
||||
label.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Цвет Deck («RRGGBB» без #, иногда с #). */
|
||||
internal fun parseDeckColor(hex: String?): Color? {
|
||||
val h = hex?.trim()?.removePrefix("#")?.takeIf { it.length == 6 } ?: return null
|
||||
return runCatching { Color(("FF$h").toLong(16)) }.getOrNull()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -15,6 +16,8 @@ data class DeckUiState(
|
||||
val boards: List<DeckBoard> = emptyList(),
|
||||
val selectedBoardId: Int? = null,
|
||||
val boardDetail: DeckBoardDetail? = null,
|
||||
val openedCard: DeckCard? = null,
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
val unauthorized: Boolean = false,
|
||||
)
|
||||
@@ -27,61 +30,116 @@ class DeckViewModel(
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
runCatching { repository.loadBoards(session) }
|
||||
.onSuccess { boards ->
|
||||
_state.value = _state.value.copy(loading = false, boards = boards, error = null)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
.onSuccess { boards -> _state.update { it.copy(loading = false, boards = boards, error = null) } }
|
||||
.onFailure { t -> fail(t) }
|
||||
}
|
||||
}
|
||||
|
||||
fun openBoard(session: AuthSession, boardId: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(
|
||||
loading = true,
|
||||
selectedBoardId = boardId,
|
||||
boardDetail = null,
|
||||
error = null,
|
||||
)
|
||||
_state.update { it.copy(loading = true, selectedBoardId = boardId, boardDetail = null, error = null) }
|
||||
runCatching { repository.loadBoardDetail(session, boardId) }
|
||||
.onSuccess { detail -> _state.update { it.copy(loading = false, boardDetail = detail) } }
|
||||
.onFailure { t -> fail(t) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadBoard(session: AuthSession) {
|
||||
val boardId = _state.value.selectedBoardId ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.loadBoardDetail(session, boardId) }
|
||||
.onSuccess { detail ->
|
||||
_state.value = _state.value.copy(loading = false, boardDetail = detail)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
// если открыта карточка — подставим её свежую версию
|
||||
val openedId = _state.value.openedCard?.id
|
||||
val freshCard = openedId?.let { id ->
|
||||
detail.stacks.flatMap { it.cards }.firstOrNull { it.id == id }
|
||||
}
|
||||
_state.update { it.copy(boardDetail = detail, openedCard = freshCard ?: it.openedCard, busy = false) }
|
||||
}
|
||||
.onFailure { t -> fail(t) }
|
||||
}
|
||||
}
|
||||
|
||||
fun closeBoard() {
|
||||
_state.value = _state.value.copy(selectedBoardId = null, boardDetail = null)
|
||||
_state.update { it.copy(selectedBoardId = null, boardDetail = null, openedCard = null) }
|
||||
}
|
||||
|
||||
fun openCard(card: DeckCard) {
|
||||
_state.update { it.copy(openedCard = card) }
|
||||
}
|
||||
|
||||
fun closeCard() {
|
||||
_state.update { it.copy(openedCard = null) }
|
||||
}
|
||||
|
||||
fun openCardById(session: AuthSession, cardId: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
runCatching { repository.loadCard(session, cardId) }
|
||||
.onSuccess { card ->
|
||||
openBoard(session, card.boardId)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message ?: "Не удалось открыть карточку",
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
.onSuccess { card -> openBoard(session, card.boardId) }
|
||||
.onFailure { t -> fail(t, "Не удалось открыть карточку") }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Действия (оптимистично не мутируем, перечитываем доску) ---
|
||||
|
||||
fun createCard(session: AuthSession, stackId: Int, title: String) =
|
||||
mutate(session) { repository.createCard(session, stackId, title.trim()) }
|
||||
|
||||
fun toggleDone(session: AuthSession, card: DeckCard) =
|
||||
mutate(session) { repository.setCardDone(session, card.id, !card.done) }
|
||||
|
||||
fun updateCard(session: AuthSession, card: DeckCard, title: String, description: String, duedate: String?) =
|
||||
mutate(session) { repository.updateCard(session, card, title.trim(), description.trim(), duedate) }
|
||||
|
||||
fun moveCard(session: AuthSession, card: DeckCard, targetStackId: Int) =
|
||||
mutate(session) { repository.moveCard(session, card.id, targetStackId) }
|
||||
|
||||
fun archiveCard(session: AuthSession, card: DeckCard) =
|
||||
mutate(session, closeCardAfter = true) { repository.archiveCard(session, card.id) }
|
||||
|
||||
fun deleteCard(session: AuthSession, card: DeckCard) =
|
||||
mutate(session, closeCardAfter = true) { repository.deleteCard(session, card.id) }
|
||||
|
||||
fun toggleLabel(session: AuthSession, card: DeckCard, label: DeckLabel) = mutate(session) {
|
||||
if (card.labels.any { it.id == label.id }) {
|
||||
repository.removeLabel(session, card.id, label.id)
|
||||
} else {
|
||||
repository.assignLabel(session, card.id, label.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun createStack(session: AuthSession, title: String) {
|
||||
val boardId = _state.value.selectedBoardId ?: return
|
||||
mutate(session) { repository.createStack(session, boardId, title.trim()) }
|
||||
}
|
||||
|
||||
private fun mutate(
|
||||
session: AuthSession,
|
||||
closeCardAfter: Boolean = false,
|
||||
action: () -> Unit,
|
||||
) {
|
||||
_state.update { it.copy(busy = true, error = null) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { action() }
|
||||
.onSuccess {
|
||||
if (closeCardAfter) _state.update { it.copy(openedCard = null) }
|
||||
reloadBoard(session)
|
||||
}
|
||||
.onFailure { t -> fail(t) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(t: Throwable, fallback: String? = null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
busy = false,
|
||||
error = t.message ?: fallback,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user