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:
b-dev-mobile
2026-07-10 05:33:28 +00:00
parent 7f95b07666
commit 4aa732846d
4 changed files with 820 additions and 118 deletions
@@ -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 package ru.forbion.f7cloud.feature.deck
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory 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 { class DeckRepository {
private fun apiBase(session: AuthSession): String { private fun apiBase(session: AuthSession): String =
return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0" "${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> { suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) val json = getJson(client(session), "${apiBase(session)}/boards")
val json = getJson(client, "${apiBase(session)}/boards") val array = json as? JSONArray ?: (json as? JSONObject)?.let { JSONArray().put(it) } ?: JSONArray()
val array = when (json) {
is JSONArray -> json
is JSONObject -> JSONArray().put(json)
else -> JSONArray()
}
val out = mutableListOf<DeckBoard>() val out = mutableListOf<DeckBoard>()
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val board = array.optJSONObject(i) ?: continue val board = array.optJSONObject(i) ?: continue
val id = board.optInt("id", 0) val id = board.optInt("id", 0)
val title = board.optString("title") if (id > 0 && !board.optBoolean("archived", false)) {
if (id > 0 && title.isNotBlank()) { out += DeckBoard(
out += DeckBoard(id = id, title = title, color = board.optString("color")) id = id,
title = board.optString("title"),
color = board.optString("color"),
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true,
)
} }
} }
return out return out
} }
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail { suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) val json = getJson(client(session), "${apiBase(session)}/cards/$cardId") as JSONObject
val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject
val boardId = json.optInt("boardId", json.optInt("board_id", 0)) val boardId = json.optInt("boardId", json.optInt("board_id", 0))
if (boardId <= 0) error("Карточка не найдена") if (boardId <= 0) error("Карточка не найдена")
return DeckCardDetail( return DeckCardDetail(cardId = cardId, boardId = boardId, title = json.optString("title"))
cardId = cardId,
boardId = boardId,
title = json.optString("title"),
)
} }
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail { suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) val c = client(session)
val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks") val board = getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject
val stacksArray = when (stacksJson) { val labels = parseLabels(board.optJSONArray("labels"))
is JSONArray -> stacksJson val canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
else -> JSONArray() val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray()
}
val stacks = mutableListOf<DeckStack>() val stacks = mutableListOf<DeckStack>()
for (i in 0 until stacksArray.length()) { for (i in 0 until stacksJson.length()) {
val stack = stacksArray.optJSONObject(i) ?: continue val stack = stacksJson.optJSONObject(i) ?: continue
val stackId = stack.optInt("id", 0) val stackId = stack.optInt("id", 0)
val title = stack.optString("title") if (stackId <= 0) continue
val cards = mutableListOf<DeckCard>()
val cardsArray = stack.optJSONArray("cards") ?: JSONArray() val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
for (c in 0 until cardsArray.length()) { val cards = mutableListOf<DeckCard>()
val card = cardsArray.optJSONObject(c) ?: continue for (cIdx in 0 until cardsArray.length()) {
val cardTitle = card.optString("title") cardsArray.optJSONObject(cIdx)?.let { cards += parseCard(it, stackId) }
if (cardTitle.isNotBlank()) { }
cards += DeckCard( stacks += DeckStack(
id = card.optInt("id", 0), id = stackId,
title = cardTitle, boardId = boardId,
done = card.has("done") && !card.isNull("done"), title = stack.optString("title").ifBlank { "Колонка" },
order = stack.optInt("order", i),
cards = cards.sortedBy { it.order },
) )
} }
} return DeckBoardDetail(
if (stackId > 0) { boardId = boardId,
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards) title = board.optString("title"),
} canEdit = canEdit,
} labels = labels,
return DeckBoardDetail(boardId = boardId, stacks = stacks) stacks = stacks.sortedBy { it.order },
)
} }
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any { private fun parseCard(card: JSONObject, stackId: Int): DeckCard = DeckCard(
val request = Request.Builder().url(url).build() id = card.optInt("id", 0),
client.newCall(request).execute().use { response -> title = card.optString("title"),
if (!response.isSuccessful || response.body == null) { done = !card.isNull("done") && card.optString("done").isNotBlank(),
error("Deck API HTTP ${response.code}") 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}")
val body = response.body!!.string().trim() val body = response.body!!.string().trim()
if (body.startsWith("[")) return JSONArray(body) return when {
if (body.startsWith("{")) return JSONObject(body) body.startsWith("[") -> JSONArray(body)
return JSONArray() 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 id: Int,
val title: String, val title: String,
val color: String, val color: String,
val canEdit: Boolean = true,
)
data class DeckLabel(
val id: Int,
val title: String,
val color: String,
) )
data class DeckStack( data class DeckStack(
val id: Int, val id: Int,
val boardId: Int,
val title: String, val title: String,
val order: Int,
val cards: List<DeckCard>, val cards: List<DeckCard>,
) )
@@ -106,10 +243,19 @@ data class DeckCard(
val id: Int, val id: Int,
val title: String, val title: String,
val done: Boolean, 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( data class DeckBoardDetail(
val boardId: Int, val boardId: Int,
val title: String,
val canEdit: Boolean,
val labels: List<DeckLabel>,
val stacks: List<DeckStack>, val stacks: List<DeckStack>,
) )
@@ -1,26 +1,55 @@
package ru.forbion.f7cloud.feature.deck 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.LazyColumn
import androidx.compose.foundation.lazy.items 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession 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.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun DeckScreen( fun DeckScreen(
session: AuthSession, session: AuthSession,
@@ -31,23 +60,18 @@ fun DeckScreen(
) { ) {
val vm: DeckViewModel = viewModel() val vm: DeckViewModel = viewModel()
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
var addStackOpen by remember { mutableStateOf(false) }
var addCardStackId by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(session.serverUrl, session.username) { LaunchedEffect(session.serverUrl, session.username) { vm.load(session) }
vm.load(session)
}
LaunchedEffect(openCardId) { LaunchedEffect(openCardId) {
val cardId = openCardId ?: return@LaunchedEffect val cardId = openCardId ?: return@LaunchedEffect
vm.openCardById(session, cardId) vm.openCardById(session, cardId)
onOpenCardConsumed() onOpenCardConsumed()
} }
LaunchedEffect(state.unauthorized) { LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() }
if (state.unauthorized) onUnauthorized()
}
F7OverlayDismissHandler( F7OverlayDismissHandler(enabled = state.boardDetail != null, onDismiss = vm::closeBoard)
enabled = state.boardDetail != null,
onDismiss = vm::closeBoard,
)
F7ModuleScreen( F7ModuleScreen(
modifier = modifier, modifier = modifier,
@@ -55,7 +79,12 @@ fun DeckScreen(
error = state.error, error = state.error,
onRefresh = { vm.load(session) }, onRefresh = { vm.load(session) },
headerActions = { 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() }) F7SecondaryButton(text = "Назад", onClick = { vm.closeBoard() })
} }
}, },
@@ -66,31 +95,240 @@ fun DeckScreen(
onRefresh = { onRefresh = {
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session) if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
}, },
modifier = Modifier modifier = Modifier.weight(1f).fillMaxWidth(),
.weight(1f)
.fillMaxWidth(),
) { ) {
if (detail != null) { 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 -> items(detail.stacks, key = { it.id }) { stack ->
F7ListCard { DeckStackSection(
Text(stack.title, style = MaterialTheme.typography.titleSmall) stack = stack,
stack.cards.forEach { card -> canEdit = detail.canEdit,
val prefix = if (card.done) "" else "" onCardClick = { vm.openCard(it) },
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium) onToggleDone = { vm.toggleDone(session, it) },
} onAddCard = { addCardStackId = stack.id },
} )
} }
} }
} else { } 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 -> items(state.boards, key = { it.id }) { board ->
F7ListCard(onClick = { vm.openBoard(session, board.id) }) { DeckBoardRow(board = board, onClick = { vm.openBoard(session, board.id) })
Text(board.title, style = MaterialTheme.typography.titleSmall)
} }
} }
} }
} }
} }
// Карточка-деталь
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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException import ru.forbion.f7cloud.core.network.UnauthorizedException
@@ -15,6 +16,8 @@ data class DeckUiState(
val boards: List<DeckBoard> = emptyList(), val boards: List<DeckBoard> = emptyList(),
val selectedBoardId: Int? = null, val selectedBoardId: Int? = null,
val boardDetail: DeckBoardDetail? = null, val boardDetail: DeckBoardDetail? = null,
val openedCard: DeckCard? = null,
val busy: Boolean = false,
val error: String? = null, val error: String? = null,
val unauthorized: Boolean = false, val unauthorized: Boolean = false,
) )
@@ -27,61 +30,116 @@ class DeckViewModel(
fun load(session: AuthSession) { fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) { 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) } runCatching { repository.loadBoards(session) }
.onSuccess { boards -> .onSuccess { boards -> _state.update { it.copy(loading = false, boards = boards, error = null) } }
_state.value = _state.value.copy(loading = false, boards = boards, error = null) .onFailure { t -> fail(t) }
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
} }
} }
fun openBoard(session: AuthSession, boardId: Int) { fun openBoard(session: AuthSession, boardId: Int) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy( _state.update { it.copy(loading = true, selectedBoardId = boardId, boardDetail = null, error = null) }
loading = true, runCatching { repository.loadBoardDetail(session, boardId) }
selectedBoardId = boardId, .onSuccess { detail -> _state.update { it.copy(loading = false, boardDetail = detail) } }
boardDetail = null, .onFailure { t -> fail(t) }
error = null, }
) }
private fun reloadBoard(session: AuthSession) {
val boardId = _state.value.selectedBoardId ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.loadBoardDetail(session, boardId) } runCatching { repository.loadBoardDetail(session, boardId) }
.onSuccess { detail -> .onSuccess { detail ->
_state.value = _state.value.copy(loading = false, boardDetail = detail) // если открыта карточка — подставим её свежую версию
val openedId = _state.value.openedCard?.id
val freshCard = openedId?.let { id ->
detail.stacks.flatMap { it.cards }.firstOrNull { it.id == id }
} }
.onFailure { t -> _state.update { it.copy(boardDetail = detail, openedCard = freshCard ?: it.openedCard, busy = false) }
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
} }
.onFailure { t -> fail(t) }
} }
} }
fun closeBoard() { 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) { fun openCardById(session: AuthSession, cardId: Int) {
viewModelScope.launch(Dispatchers.IO) { 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) } runCatching { repository.loadCard(session, cardId) }
.onSuccess { card -> .onSuccess { card -> openBoard(session, card.boardId) }
openBoard(session, card.boardId) .onFailure { t -> fail(t, "Не удалось открыть карточку") }
} }
.onFailure { t -> }
_state.value = _state.value.copy(
// --- Действия (оптимистично не мутируем, перечитываем доску) ---
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, loading = false,
error = t.message ?: "Не удалось открыть карточку", busy = false,
error = t.message ?: fallback,
unauthorized = t is UnauthorizedException, unauthorized = t is UnauthorizedException,
) )
} }
} }
} }
}