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
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user