diff --git a/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckCardSheet.kt b/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckCardSheet.kt new file mode 100644 index 0000000..5bd6181 --- /dev/null +++ b/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckCardSheet.kt @@ -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, + stacks: List, + 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")) +} diff --git a/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckRepository.kt b/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckRepository.kt index 4799e76..b32c5e0 100644 --- a/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckRepository.kt +++ b/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckRepository.kt @@ -1,91 +1,219 @@ package ru.forbion.f7cloud.feature.deck +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.network.NetworkFactory -import ru.forbion.f7cloud.core.network.UnauthorizedException +/** + * Deck через внутренний фронт-API (`/index.php/apps/deck/api/v1.0`) — тот же, что у веб-версии. + * Пишущие запросы авторизуются app-password + OCS-APIRequest (CSRF при app-password не требуется). + */ class DeckRepository { - private fun apiBase(session: AuthSession): String { - return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0" - } + private fun apiBase(session: AuthSession): String = + "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0" + + private fun client(session: AuthSession): OkHttpClient = + NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) + + // --- Чтение --- suspend fun loadBoards(session: AuthSession): List { - val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) - val json = getJson(client, "${apiBase(session)}/boards") - val array = when (json) { - is JSONArray -> json - is JSONObject -> JSONArray().put(json) - else -> JSONArray() - } + val json = getJson(client(session), "${apiBase(session)}/boards") + val array = json as? JSONArray ?: (json as? JSONObject)?.let { JSONArray().put(it) } ?: JSONArray() val out = mutableListOf() for (i in 0 until array.length()) { val board = array.optJSONObject(i) ?: continue val id = board.optInt("id", 0) - val title = board.optString("title") - if (id > 0 && title.isNotBlank()) { - out += DeckBoard(id = id, title = title, color = board.optString("color")) + if (id > 0 && !board.optBoolean("archived", false)) { + out += DeckBoard( + id = id, + title = board.optString("title"), + color = board.optString("color"), + canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true, + ) } } return out } suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail { - val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) - val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject + val json = getJson(client(session), "${apiBase(session)}/cards/$cardId") as JSONObject val boardId = json.optInt("boardId", json.optInt("board_id", 0)) if (boardId <= 0) error("Карточка не найдена") - return DeckCardDetail( - cardId = cardId, - boardId = boardId, - title = json.optString("title"), - ) + return DeckCardDetail(cardId = cardId, boardId = boardId, title = json.optString("title")) } suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail { - val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) - val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks") - val stacksArray = when (stacksJson) { - is JSONArray -> stacksJson - else -> JSONArray() - } + val c = client(session) + val board = getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject + val labels = parseLabels(board.optJSONArray("labels")) + val canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true + val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray() val stacks = mutableListOf() - for (i in 0 until stacksArray.length()) { - val stack = stacksArray.optJSONObject(i) ?: continue + for (i in 0 until stacksJson.length()) { + val stack = stacksJson.optJSONObject(i) ?: continue val stackId = stack.optInt("id", 0) - val title = stack.optString("title") - val cards = mutableListOf() + if (stackId <= 0) continue val cardsArray = stack.optJSONArray("cards") ?: JSONArray() - for (c in 0 until cardsArray.length()) { - val card = cardsArray.optJSONObject(c) ?: continue - val cardTitle = card.optString("title") - if (cardTitle.isNotBlank()) { - cards += DeckCard( - id = card.optInt("id", 0), - title = cardTitle, - done = card.has("done") && !card.isNull("done"), - ) - } - } - if (stackId > 0) { - stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards) + val cards = mutableListOf() + for (cIdx in 0 until cardsArray.length()) { + cardsArray.optJSONObject(cIdx)?.let { cards += parseCard(it, stackId) } } + stacks += DeckStack( + id = stackId, + boardId = boardId, + title = stack.optString("title").ifBlank { "Колонка" }, + order = stack.optInt("order", i), + cards = cards.sortedBy { it.order }, + ) } - return DeckBoardDetail(boardId = boardId, stacks = stacks) + return DeckBoardDetail( + boardId = boardId, + title = board.optString("title"), + canEdit = canEdit, + labels = labels, + stacks = stacks.sortedBy { it.order }, + ) } - private fun getJson(client: okhttp3.OkHttpClient, url: String): Any { - val request = Request.Builder().url(url).build() + private fun parseCard(card: JSONObject, stackId: Int): DeckCard = DeckCard( + id = card.optInt("id", 0), + title = card.optString("title"), + done = !card.isNull("done") && card.optString("done").isNotBlank(), + description = card.optString("description"), + duedate = card.optString("duedate").takeIf { it.isNotBlank() && it != "null" }, + order = card.optInt("order", 0), + stackId = stackId, + labels = parseLabels(card.optJSONArray("labels")), + assignees = parseAssignees(card.optJSONArray("assignedUsers")), + ) + + private fun parseLabels(array: JSONArray?): List { + if (array == null) return emptyList() + val out = mutableListOf() + 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 { + if (array == null) return emptyList() + val out = mutableListOf() + for (i in 0 until array.length()) { + val a = array.optJSONObject(i) ?: continue + val p = a.optJSONObject("participant") + val name = p?.optString("displayname")?.takeIf { it.isNotBlank() } + ?: p?.optString("uid")?.takeIf { it.isNotBlank() } + ?: a.optString("displayname") + if (name.isNotBlank()) out += name + } + return out + } + + // --- Запись --- + + fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard { + val body = JSONObject().put("title", title).put("type", "plain").put("order", 999).put("stackId", stackId) + val json = sendJson(session, "POST", "${apiBase(session)}/cards", body) as? JSONObject + return json?.let { parseCard(it, stackId) } + ?: DeckCard(id = 0, title = title, done = false, stackId = stackId) + } + + fun updateCard( + session: AuthSession, + card: DeckCard, + title: String, + description: String, + duedate: String?, + ) { + val body = JSONObject() + .put("title", title) + .put("type", "plain") + .put("owner", session.username) + .put("description", description) + .put("order", card.order) + .put("stackId", card.stackId) + .put("duedate", duedate ?: JSONObject.NULL) + sendJson(session, "PUT", "${apiBase(session)}/cards/${card.id}", body) + } + + fun setCardDone(session: AuthSession, cardId: Int, done: Boolean) { + val path = if (done) "done" else "undone" + sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/$path", JSONObject()) + } + + fun moveCard(session: AuthSession, cardId: Int, targetStackId: Int, order: Int = 0) { + val body = JSONObject().put("stackId", targetStackId).put("order", order) + sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/reorder", body) + } + + fun archiveCard(session: AuthSession, cardId: Int) { + sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/archive", JSONObject()) + } + + fun deleteCard(session: AuthSession, cardId: Int) { + sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId", null) + } + + fun assignLabel(session: AuthSession, cardId: Int, labelId: Int) { + sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/label/$labelId", JSONObject()) + } + + fun removeLabel(session: AuthSession, cardId: Int, labelId: Int) { + sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null) + } + + fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack { + val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999) + val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject + val id = json?.optInt("id", 0) ?: 0 + return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList()) + } + + // --- HTTP --- + + private fun getJson(client: OkHttpClient, url: String): Any { + val request = Request.Builder().url(url).header("OCS-APIRequest", "true").build() client.newCall(request).execute().use { response -> - if (!response.isSuccessful || response.body == null) { - error("Deck API HTTP ${response.code}") - } + if (!response.isSuccessful || response.body == null) error("Deck API HTTP ${response.code}") val body = response.body!!.string().trim() - if (body.startsWith("[")) return JSONArray(body) - if (body.startsWith("{")) return JSONObject(body) - return JSONArray() + return when { + body.startsWith("[") -> JSONArray(body) + body.startsWith("{") -> JSONObject(body) + else -> JSONArray() + } + } + } + + private val jsonMedia = "application/json".toMediaType() + + private fun sendJson(session: AuthSession, method: String, url: String, body: JSONObject?): Any? { + val payload = (body?.toString() ?: "{}").toRequestBody(jsonMedia) + val builder = Request.Builder() + .url(url) + .header("OCS-APIRequest", "true") + .header("Accept", "application/json") + when (method) { + "POST" -> builder.post(payload) + "PUT" -> builder.put(payload) + "DELETE" -> if (body != null) builder.delete(payload) else builder.delete() + } + client(session).newCall(builder.build()).execute().use { response -> + if (!response.isSuccessful) error("Deck API HTTP ${response.code}") + val text = response.body?.string()?.trim().orEmpty() + return when { + text.startsWith("{") -> JSONObject(text) + text.startsWith("[") -> JSONArray(text) + else -> null + } } } } @@ -94,11 +222,20 @@ data class DeckBoard( val id: Int, val title: String, val color: String, + val canEdit: Boolean = true, +) + +data class DeckLabel( + val id: Int, + val title: String, + val color: String, ) data class DeckStack( val id: Int, + val boardId: Int, val title: String, + val order: Int, val cards: List, ) @@ -106,10 +243,19 @@ data class DeckCard( val id: Int, val title: String, val done: Boolean, + val description: String = "", + val duedate: String? = null, + val order: Int = 0, + val stackId: Int = 0, + val labels: List = emptyList(), + val assignees: List = emptyList(), ) data class DeckBoardDetail( val boardId: Int, + val title: String, + val canEdit: Boolean, + val labels: List, val stacks: List, ) diff --git a/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckScreen.kt b/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckScreen.kt index 34b1cbc..c67b566 100644 --- a/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckScreen.kt +++ b/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckScreen.kt @@ -1,26 +1,55 @@ package ru.forbion.f7cloud.feature.deck +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import ru.forbion.f7cloud.core.auth.AuthSession -import ru.forbion.f7cloud.core.designsystem.F7ListCard +import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DeckScreen( session: AuthSession, @@ -31,23 +60,18 @@ fun DeckScreen( ) { val vm: DeckViewModel = viewModel() val state by vm.state.collectAsState() + var addStackOpen by remember { mutableStateOf(false) } + var addCardStackId by remember { mutableStateOf(null) } - LaunchedEffect(session.serverUrl, session.username) { - vm.load(session) - } + LaunchedEffect(session.serverUrl, session.username) { vm.load(session) } LaunchedEffect(openCardId) { val cardId = openCardId ?: return@LaunchedEffect vm.openCardById(session, cardId) onOpenCardConsumed() } - LaunchedEffect(state.unauthorized) { - if (state.unauthorized) onUnauthorized() - } + LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() } - F7OverlayDismissHandler( - enabled = state.boardDetail != null, - onDismiss = vm::closeBoard, - ) + F7OverlayDismissHandler(enabled = state.boardDetail != null, onDismiss = vm::closeBoard) F7ModuleScreen( modifier = modifier, @@ -55,7 +79,12 @@ fun DeckScreen( error = state.error, onRefresh = { vm.load(session) }, headerActions = { - if (state.selectedBoardId != null) { + val detail = state.boardDetail + if (detail != null) { + if (detail.canEdit) { + F7SecondaryButton(text = "+ Колонка", onClick = { addStackOpen = true }) + Spacer(Modifier.size(8.dp)) + } F7SecondaryButton(text = "Назад", onClick = { vm.closeBoard() }) } }, @@ -66,31 +95,240 @@ fun DeckScreen( onRefresh = { if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session) }, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), + modifier = Modifier.weight(1f).fillMaxWidth(), ) { if (detail != null) { - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { items(detail.stacks, key = { it.id }) { stack -> - F7ListCard { - Text(stack.title, style = MaterialTheme.typography.titleSmall) - stack.cards.forEach { card -> - val prefix = if (card.done) "✓ " else "• " - Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium) - } - } + DeckStackSection( + stack = stack, + canEdit = detail.canEdit, + onCardClick = { vm.openCard(it) }, + onToggleDone = { vm.toggleDone(session, it) }, + onAddCard = { addCardStackId = stack.id }, + ) } } } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { items(state.boards, key = { it.id }) { board -> - F7ListCard(onClick = { vm.openBoard(session, board.id) }) { - Text(board.title, style = MaterialTheme.typography.titleSmall) - } + DeckBoardRow(board = board, onClick = { vm.openBoard(session, board.id) }) } } } } } + + // Карточка-деталь + state.openedCard?.let { card -> + DeckCardSheet( + card = card, + boardLabels = state.boardDetail?.labels.orEmpty(), + stacks = state.boardDetail?.stacks.orEmpty(), + canEdit = state.boardDetail?.canEdit ?: false, + busy = state.busy, + onDismiss = vm::closeCard, + onToggleDone = { vm.toggleDone(session, card) }, + onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) }, + onToggleLabel = { vm.toggleLabel(session, card, it) }, + onMove = { vm.moveCard(session, card, it) }, + onArchive = { vm.archiveCard(session, card) }, + onDelete = { vm.deleteCard(session, card) }, + ) + } + + if (addStackOpen) { + DeckTextPromptDialog( + title = "Новая колонка", + label = "Название колонки", + confirmText = "Создать", + onConfirm = { vm.createStack(session, it); addStackOpen = false }, + onDismiss = { addStackOpen = false }, + ) + } + addCardStackId?.let { stackId -> + DeckTextPromptDialog( + title = "Новая карточка", + label = "Название карточки", + confirmText = "Создать", + onConfirm = { vm.createCard(session, stackId, it); addCardStackId = null }, + onDismiss = { addCardStackId = null }, + ) + } +} + +@Composable +private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFFDFDFD)) + .border(1.dp, Color(0xFFF0F1F4), RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(parseDeckColor(board.color) ?: F7Colors.Primary), + ) + Text( + board.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = F7Colors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DeckStackSection( + stack: DeckStack, + canEdit: Boolean, + onCardClick: (DeckCard) -> Unit, + onToggleDone: (DeckCard) -> Unit, + onAddCard: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(F7Colors.Grey2) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stack.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = F7Colors.TextPrimary, + ) + Text( + stack.cards.size.toString(), + style = MaterialTheme.typography.bodyMedium, + color = F7Colors.TextSecondary, + ) + if (canEdit) { + Spacer(Modifier.size(8.dp)) + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(F7Colors.PrimaryLight) + .clickable(onClick = onAddCard), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp)) + } + } + } + stack.cards.forEach { card -> + DeckCardRow(card = card, onClick = { onCardClick(card) }, onToggleDone = { onToggleDone(card) }) + } + if (stack.cards.isEmpty()) { + Text("Нет карточек", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFFDFDFD)) + .border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (card.labels.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + card.labels.forEach { DeckLabelChip(it) } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .border(1.5.dp, if (card.done) F7Colors.Primary else F7Colors.Grey1, RoundedCornerShape(4.dp)) + .background(if (card.done) F7Colors.Primary else Color.Transparent) + .clickable(onClick = onToggleDone), + contentAlignment = Alignment.Center, + ) { + if (card.done) Text("✓", color = Color.White, style = MaterialTheme.typography.labelSmall) + } + Spacer(Modifier.size(8.dp)) + Text( + card.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = F7Colors.TextPrimary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + val due = card.duedate?.let { formatDeckDue(it) } + if (due != null || card.assignees.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(start = 28.dp), + ) { + if (due != null) { + Text("🗓 $due", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary) + } + if (card.assignees.isNotEmpty()) { + Text("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +internal fun DeckLabelChip(label: DeckLabel) { + val bg = parseDeckColor(label.color) ?: F7Colors.Primary + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(bg.copy(alpha = 0.22f)) + .border(1.dp, bg, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) { + Text( + label.title, + style = MaterialTheme.typography.labelMedium, + color = F7Colors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** Цвет Deck («RRGGBB» без #, иногда с #). */ +internal fun parseDeckColor(hex: String?): Color? { + val h = hex?.trim()?.removePrefix("#")?.takeIf { it.length == 6 } ?: return null + return runCatching { Color(("FF$h").toLong(16)) }.getOrNull() } diff --git a/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckViewModel.kt b/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckViewModel.kt index cc3b705..c9515e2 100644 --- a/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckViewModel.kt +++ b/feature/deck/src/main/java/ru/forbion/f7cloud/feature/deck/DeckViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.network.UnauthorizedException @@ -15,6 +16,8 @@ data class DeckUiState( val boards: List = emptyList(), val selectedBoardId: Int? = null, val boardDetail: DeckBoardDetail? = null, + val openedCard: DeckCard? = null, + val busy: Boolean = false, val error: String? = null, val unauthorized: Boolean = false, ) @@ -27,61 +30,116 @@ class DeckViewModel( fun load(session: AuthSession) { viewModelScope.launch(Dispatchers.IO) { - _state.value = _state.value.copy(loading = true, error = null) + _state.update { it.copy(loading = true, error = null) } runCatching { repository.loadBoards(session) } - .onSuccess { boards -> - _state.value = _state.value.copy(loading = false, boards = boards, error = null) - } - .onFailure { t -> - _state.value = _state.value.copy( - loading = false, - error = t.message, - unauthorized = t is UnauthorizedException, - ) - } + .onSuccess { boards -> _state.update { it.copy(loading = false, boards = boards, error = null) } } + .onFailure { t -> fail(t) } } } fun openBoard(session: AuthSession, boardId: Int) { viewModelScope.launch(Dispatchers.IO) { - _state.value = _state.value.copy( - loading = true, - selectedBoardId = boardId, - boardDetail = null, - error = null, - ) + _state.update { it.copy(loading = true, selectedBoardId = boardId, boardDetail = null, error = null) } + runCatching { repository.loadBoardDetail(session, boardId) } + .onSuccess { detail -> _state.update { it.copy(loading = false, boardDetail = detail) } } + .onFailure { t -> fail(t) } + } + } + + private fun reloadBoard(session: AuthSession) { + val boardId = _state.value.selectedBoardId ?: return + viewModelScope.launch(Dispatchers.IO) { runCatching { repository.loadBoardDetail(session, boardId) } .onSuccess { detail -> - _state.value = _state.value.copy(loading = false, boardDetail = detail) - } - .onFailure { t -> - _state.value = _state.value.copy( - loading = false, - error = t.message, - unauthorized = t is UnauthorizedException, - ) + // если открыта карточка — подставим её свежую версию + val openedId = _state.value.openedCard?.id + val freshCard = openedId?.let { id -> + detail.stacks.flatMap { it.cards }.firstOrNull { it.id == id } + } + _state.update { it.copy(boardDetail = detail, openedCard = freshCard ?: it.openedCard, busy = false) } } + .onFailure { t -> fail(t) } } } fun closeBoard() { - _state.value = _state.value.copy(selectedBoardId = null, boardDetail = null) + _state.update { it.copy(selectedBoardId = null, boardDetail = null, openedCard = null) } + } + + fun openCard(card: DeckCard) { + _state.update { it.copy(openedCard = card) } + } + + fun closeCard() { + _state.update { it.copy(openedCard = null) } } fun openCardById(session: AuthSession, cardId: Int) { viewModelScope.launch(Dispatchers.IO) { - _state.value = _state.value.copy(loading = true, error = null) + _state.update { it.copy(loading = true, error = null) } runCatching { repository.loadCard(session, cardId) } - .onSuccess { card -> - openBoard(session, card.boardId) - } - .onFailure { t -> - _state.value = _state.value.copy( - loading = false, - error = t.message ?: "Не удалось открыть карточку", - unauthorized = t is UnauthorizedException, - ) + .onSuccess { card -> openBoard(session, card.boardId) } + .onFailure { t -> fail(t, "Не удалось открыть карточку") } + } + } + + // --- Действия (оптимистично не мутируем, перечитываем доску) --- + + fun createCard(session: AuthSession, stackId: Int, title: String) = + mutate(session) { repository.createCard(session, stackId, title.trim()) } + + fun toggleDone(session: AuthSession, card: DeckCard) = + mutate(session) { repository.setCardDone(session, card.id, !card.done) } + + fun updateCard(session: AuthSession, card: DeckCard, title: String, description: String, duedate: String?) = + mutate(session) { repository.updateCard(session, card, title.trim(), description.trim(), duedate) } + + fun moveCard(session: AuthSession, card: DeckCard, targetStackId: Int) = + mutate(session) { repository.moveCard(session, card.id, targetStackId) } + + fun archiveCard(session: AuthSession, card: DeckCard) = + mutate(session, closeCardAfter = true) { repository.archiveCard(session, card.id) } + + fun deleteCard(session: AuthSession, card: DeckCard) = + mutate(session, closeCardAfter = true) { repository.deleteCard(session, card.id) } + + fun toggleLabel(session: AuthSession, card: DeckCard, label: DeckLabel) = mutate(session) { + if (card.labels.any { it.id == label.id }) { + repository.removeLabel(session, card.id, label.id) + } else { + repository.assignLabel(session, card.id, label.id) + } + } + + fun createStack(session: AuthSession, title: String) { + val boardId = _state.value.selectedBoardId ?: return + mutate(session) { repository.createStack(session, boardId, title.trim()) } + } + + private fun mutate( + session: AuthSession, + closeCardAfter: Boolean = false, + action: () -> Unit, + ) { + _state.update { it.copy(busy = true, error = null) } + viewModelScope.launch(Dispatchers.IO) { + runCatching { action() } + .onSuccess { + if (closeCardAfter) _state.update { it.copy(openedCard = null) } + reloadBoard(session) } + .onFailure { t -> fail(t) } + } + } + + private fun fail(t: Throwable, fallback: String? = null) { + _state.update { + it.copy( + loading = false, + busy = false, + error = t.message ?: fallback, + unauthorized = t is UnauthorizedException, + ) } } }