From 3af47ba55a1418e86d124b0cb2ed70412e3fe8fb Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:08:03 -0400 Subject: [PATCH 01/16] feat: define board detail domain and mutation contracts --- AGENTS.md | 2 + .../app/boarddetail/BoardDetailModels.kt | 38 ++++++++++++ .../app/boarddetail/BoardDetailModelsTest.kt | 58 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModels.kt create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModelsTest.kt diff --git a/AGENTS.md b/AGENTS.md index 604415f..5f91e34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,8 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel **Board detail view** +- Current status: domain model and mutation contracts are defined in `app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModels.kt`, with unit coverage in `app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModelsTest.kt`. + - The board detail view shows the lists in the board as vertical lists. - Each list has it's title at the top. - Clicking on the title of a list allows editing the title. diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModels.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModels.kt new file mode 100644 index 0000000..217cd7a --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModels.kt @@ -0,0 +1,38 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +data class BoardDetail( + val id: String, + val title: String, + val lists: List, +) + +data class BoardListDetail( + val id: String, + val title: String, + val cards: List, +) + +data class BoardCardSummary( + val id: String, + val title: String, + val tags: List, + val dueAtEpochMillis: Long?, +) + +data class BoardTagSummary( + val id: String, + val name: String, + val colorHex: String, +) + +sealed interface CardBatchMutationResult { + data object Success : CardBatchMutationResult + data class PartialSuccess( + val failedCardIds: Set, + val message: String, + ) : CardBatchMutationResult + + data class Failure( + val message: String, + ) : CardBatchMutationResult +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModelsTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModelsTest.kt new file mode 100644 index 0000000..b7353e1 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModelsTest.kt @@ -0,0 +1,58 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BoardDetailModelsTest { + + @Test + fun boardDetailModelsExposeRequiredFields() { + val tag = BoardTagSummary( + id = "tag-1", + name = "Urgent", + colorHex = "#FF0000", + ) + val card = BoardCardSummary( + id = "card-1", + title = "Fix sync bug", + tags = listOf(tag), + dueAtEpochMillis = null, + ) + val list = BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf(card), + ) + val detail = BoardDetail( + id = "board-1", + title = "Sprint", + lists = listOf(list), + ) + + assertEquals("tag-1", tag.id) + assertEquals("Urgent", tag.name) + assertEquals("#FF0000", tag.colorHex) + assertEquals("card-1", card.id) + assertEquals("Fix sync bug", card.title) + assertEquals(listOf(tag), card.tags) + assertNull(card.dueAtEpochMillis) + assertEquals("list-1", list.id) + assertEquals("To Do", list.title) + assertEquals(listOf(card), list.cards) + assertEquals("board-1", detail.id) + assertEquals("Sprint", detail.title) + assertEquals(listOf(list), detail.lists) + } + + @Test + fun partialSuccessCarriesFailedCardIds() { + val result = CardBatchMutationResult.PartialSuccess( + failedCardIds = setOf("card-2", "card-9"), + message = "Some cards could not be updated.", + ) + + assertEquals(setOf("card-2", "card-9"), result.failedCardIds) + assertEquals("Some cards could not be updated.", result.message) + } +} From 3cff9192228324f3438ecc7c25d55213c7147954 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:09:15 -0400 Subject: [PATCH 02/16] chore: remove unintended AGENTS.md change from task 1 --- AGENTS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f91e34..604415f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,8 +76,6 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel **Board detail view** -- Current status: domain model and mutation contracts are defined in `app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModels.kt`, with unit coverage in `app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailModelsTest.kt`. - - The board detail view shows the lists in the board as vertical lists. - Each list has it's title at the top. - Clicking on the title of a list allows editing the title. From 6ea0bd1a2fcf4983426d454d58bc04f22db1c070 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:20:42 -0400 Subject: [PATCH 03/16] feat: add board detail and card/list mutation API methods --- AGENTS.md | 1 + .../kanbn4droid/app/BoardsFlowTest.kt | 27 ++ .../kanbn4droid/app/LoginFlowTest.kt | 28 ++ .../kanbn4droid/app/auth/KanbnApiClient.kt | 416 +++++++++++++++++- ...ttpKanbnApiClientBoardDetailParsingTest.kt | 357 +++++++++++++++ .../app/boards/BoardsRepositoryTest.kt | 27 ++ .../app/boards/BoardsViewModelTest.kt | 27 ++ 7 files changed, 874 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt diff --git a/AGENTS.md b/AGENTS.md index 604415f..1809c0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - Compile/target SDK: API 35. - Baseline tests: - JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`. + - JVM unit tests for board detail parsing and board/list/card API request mapping in `app/src/test/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt`. - JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`. - Instrumentation tests for login and boards flows in `app/src/androidTest/`. diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt index fbd6c8e..55d51eb 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -24,6 +24,7 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @@ -199,5 +200,31 @@ class BoardsFlowTest { boards.removeAll { it.id == boardId } return BoardsApiResult.Success(Unit) } + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } } } diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt index 67350a9..57a8e93 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt @@ -22,6 +22,8 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @RunWith(AndroidJUnit4::class) class LoginFlowTest { @@ -200,5 +202,31 @@ class LoginFlowTest { private val result: AuthResult, ) : KanbnApiClient { override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index b673917..ab8c586 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -3,10 +3,15 @@ package space.hackenslacker.kanbn4droid.app.auth import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.time.Instant import org.json.JSONArray import org.json.JSONObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @@ -48,6 +53,22 @@ interface KanbnApiClient { suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { return BoardsApiResult.Failure("Board deletion is not implemented.") } + + suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Board detail is not implemented.") + } + + suspend fun renameList(baseUrl: String, apiKey: String, listId: String, newTitle: String): BoardsApiResult { + return BoardsApiResult.Failure("List rename is not implemented.") + } + + suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult { + return BoardsApiResult.Failure("Card move is not implemented.") + } + + suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Card deletion is not implemented.") + } } class HttpKanbnApiClient : KanbnApiClient { @@ -193,6 +214,94 @@ class HttpKanbnApiClient : KanbnApiClient { } } + override suspend fun getBoardDetail( + baseUrl: String, + apiKey: String, + boardId: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/boards/$boardId", + method = "GET", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(parseBoardDetail(body, boardId)) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/lists/$listId", + method = "PATCH", + apiKey = apiKey, + body = "{\"name\":\"${jsonEscape(newTitle.trim())}\"}", + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "PATCH", + apiKey = apiKey, + body = "{\"listId\":\"${jsonEscape(targetListId)}\"}", + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun deleteCard( + baseUrl: String, + apiKey: String, + cardId: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "DELETE", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + private fun request( baseUrl: String, path: String, @@ -203,7 +312,7 @@ class HttpKanbnApiClient : KanbnApiClient { ): BoardsApiResult { val endpoint = "${baseUrl.trimEnd('/')}$path" val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { - requestMethod = method + configureRequestMethod(this, method) connectTimeout = 10_000 readTimeout = 10_000 setRequestProperty("x-api-key", apiKey) @@ -237,6 +346,18 @@ class HttpKanbnApiClient : KanbnApiClient { } } + private fun configureRequestMethod(connection: HttpURLConnection, method: String) { + try { + connection.requestMethod = method + } catch (throwable: Throwable) { + if (method != "PATCH") { + throw throwable + } + connection.requestMethod = "POST" + connection.setRequestProperty("X-HTTP-Method-Override", "PATCH") + } + } + private fun readResponseBody(connection: HttpURLConnection, code: Int): String { val stream = if (code in 200..299) connection.inputStream else connection.errorStream return stream?.bufferedReader()?.use { it.readText() }.orEmpty() @@ -361,18 +482,295 @@ class HttpKanbnApiClient : KanbnApiClient { return workspaces } + private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail { + val root = parseJsonObject(body) + ?: return BoardDetail(id = fallbackId, title = "Board", lists = emptyList()) + val data = root["data"] as? Map<*, *> + val board = (data?.get("board") as? Map<*, *>) + ?: (root["board"] as? Map<*, *>) + ?: root + + val boardId = extractId(board).ifBlank { fallbackId } + val boardTitle = extractTitle(board, "Board") + val lists = parseLists(board) + + return BoardDetail(id = boardId, title = boardTitle, lists = lists) + } + + private fun parseLists(board: Map<*, *>): List { + return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList -> + val id = extractId(rawList) + if (id.isBlank()) { + return@mapNotNull null + } + BoardListDetail( + id = id, + title = extractTitle(rawList, "List"), + cards = parseCards(rawList), + ) + } + } + + private fun parseCards(list: Map<*, *>): List { + return extractObjectArray(list, "cards", "items", "data").mapNotNull { rawCard -> + val id = extractId(rawCard) + if (id.isBlank()) { + return@mapNotNull null + } + BoardCardSummary( + id = id, + title = extractTitle(rawCard, "Card"), + tags = parseTags(rawCard), + dueAtEpochMillis = parseDueDate(rawCard), + ) + } + } + + private fun parseTags(card: Map<*, *>): List { + return extractObjectArray(card, "labels", "tags", "data").mapNotNull { rawTag -> + val id = extractId(rawTag) + if (id.isBlank()) { + return@mapNotNull null + } + BoardTagSummary( + id = id, + name = extractTitle(rawTag, "Tag"), + colorHex = extractString(rawTag, "colorHex", "color", "hex"), + ) + } + } + + private fun parseDueDate(card: Map<*, *>): Long? { + val dueValue = firstPresent(card, "dueDate", "dueAt", "due_at", "due") ?: return null + return when (dueValue) { + is Number -> dueValue.toLong() + is String -> { + val trimmed = dueValue.trim() + if (trimmed.isBlank()) null else trimmed.toLongOrNull() ?: runCatching { Instant.parse(trimmed).toEpochMilli() }.getOrNull() + } + + else -> null + } + } + + private fun extractObjectArray(source: Map<*, *>, vararg keys: String): List> { + val array = keys.firstNotNullOfOrNull { key -> source[key] as? List<*> } ?: return emptyList() + return array.mapNotNull { it as? Map<*, *> } + } + + private fun extractId(source: Map<*, *>): String { + val directId = source["id"]?.toString().orEmpty() + if (directId.isNotBlank()) { + return directId + } + return extractString(source, "publicId", "public_id") + } + + private fun extractTitle(source: Map<*, *>, fallback: String): String { + return extractString(source, "title", "name").ifBlank { fallback } + } + + private fun extractString(source: Map<*, *>, vararg keys: String): String { + return keys.firstNotNullOfOrNull { key -> source[key]?.toString()?.takeIf { it.isNotBlank() } }.orEmpty() + } + + private fun firstPresent(source: Map<*, *>, vararg keys: String): Any? { + return keys.firstNotNullOfOrNull { key -> source[key] } + } + + private fun parseJsonObject(body: String): Map? { + val trimmed = body.trim() + if (trimmed.isBlank()) { + return null + } + val parsed = runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull() + @Suppress("UNCHECKED_CAST") + return parsed as? Map + } + + private fun jsonEscape(value: String): String { + val builder = StringBuilder() + value.forEach { ch -> + when (ch) { + '\\' -> builder.append("\\\\") + '"' -> builder.append("\\\"") + '\b' -> builder.append("\\b") + '\u000C' -> builder.append("\\f") + '\n' -> builder.append("\\n") + '\r' -> builder.append("\\r") + '\t' -> builder.append("\\t") + else -> builder.append(ch) + } + } + return builder.toString() + } + + private class MiniJsonParser(private val input: String) { + private var index = 0 + + fun parseValue(): Any? { + skipWhitespace() + if (index >= input.length) { + return null + } + return when (val ch = input[index]) { + '{' -> parseObject() + '[' -> parseArray() + '"' -> parseString() + 't' -> parseLiteral("true", true) + 'f' -> parseLiteral("false", false) + 'n' -> parseLiteral("null", null) + '-', in '0'..'9' -> parseNumber() + else -> throw IllegalArgumentException("Unexpected token $ch at index $index") + } + } + + private fun parseObject(): Map { + expect('{') + skipWhitespace() + val result = linkedMapOf() + if (peek() == '}') { + index += 1 + return result + } + while (index < input.length) { + val key = parseString() + skipWhitespace() + expect(':') + val value = parseValue() + result[key] = value + skipWhitespace() + when (peek()) { + ',' -> index += 1 + '}' -> { + index += 1 + return result + } + else -> throw IllegalArgumentException("Expected , or } at index $index") + } + skipWhitespace() + } + throw IllegalArgumentException("Unclosed object") + } + + private fun parseArray(): List { + expect('[') + skipWhitespace() + val result = mutableListOf() + if (peek() == ']') { + index += 1 + return result + } + while (index < input.length) { + result += parseValue() + skipWhitespace() + when (peek()) { + ',' -> index += 1 + ']' -> { + index += 1 + return result + } + else -> throw IllegalArgumentException("Expected , or ] at index $index") + } + skipWhitespace() + } + throw IllegalArgumentException("Unclosed array") + } + + private fun parseString(): String { + expect('"') + val result = StringBuilder() + while (index < input.length) { + val ch = input[index++] + when (ch) { + '"' -> return result.toString() + '\\' -> { + val escaped = input.getOrNull(index++) ?: throw IllegalArgumentException("Invalid escape") + when (escaped) { + '"' -> result.append('"') + '\\' -> result.append('\\') + '/' -> result.append('/') + 'b' -> result.append('\b') + 'f' -> result.append('\u000C') + 'n' -> result.append('\n') + 'r' -> result.append('\r') + 't' -> result.append('\t') + 'u' -> { + val hex = input.substring(index, index + 4) + index += 4 + result.append(hex.toInt(16).toChar()) + } + else -> throw IllegalArgumentException("Invalid escape token") + } + } + else -> result.append(ch) + } + } + throw IllegalArgumentException("Unclosed string") + } + + private fun parseNumber(): Any { + val start = index + if (peek() == '-') { + index += 1 + } + while (peek()?.isDigit() == true) { + index += 1 + } + var isFloating = false + if (peek() == '.') { + isFloating = true + index += 1 + while (peek()?.isDigit() == true) { + index += 1 + } + } + if (peek() == 'e' || peek() == 'E') { + isFloating = true + index += 1 + if (peek() == '+' || peek() == '-') { + index += 1 + } + while (peek()?.isDigit() == true) { + index += 1 + } + } + val token = input.substring(start, index) + return if (isFloating) token.toDouble() else token.toLong() + } + + private fun parseLiteral(token: String, value: Any?): Any? { + if (!input.startsWith(token, index)) { + throw IllegalArgumentException("Expected $token at index $index") + } + index += token.length + return value + } + + private fun expect(expected: Char) { + skipWhitespace() + if (peek() != expected) { + throw IllegalArgumentException("Expected $expected at index $index") + } + index += 1 + } + + private fun peek(): Char? = input.getOrNull(index) + + private fun skipWhitespace() { + while (peek()?.isWhitespace() == true) { + index += 1 + } + } + } + private fun serverMessage(body: String, code: Int): String { if (body.isBlank()) { return "Server error: $code" } - return runCatching { - val root = JSONObject(body) - listOf("message", "error", "cause", "detail") - .firstNotNullOfOrNull { key -> root.optString(key).takeIf { it.isNotBlank() } } - ?: "Server error: $code" - }.getOrElse { - "Server error: $code" - } + val root = parseJsonObject(body) + val message = root?.let { extractString(it, "message", "error", "cause", "detail") }.orEmpty() + return message.ifBlank { "Server error: $code" } } } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt new file mode 100644 index 0000000..5352c43 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt @@ -0,0 +1,357 @@ +package space.hackenslacker.kanbn4droid.app.auth + +import java.io.BufferedInputStream +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.time.Instant +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +class HttpKanbnApiClientBoardDetailParsingTest { + + @Test + fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/board-1", + status = 200, + responseBody = + """ + { + "data": { + "board": { + "public_id": "board-1", + "name": "Roadmap", + "items": [ + { + "id": "list-1", + "name": "Todo", + "cards": [ + { + "publicId": "card-iso", + "name": "ISO", + "tags": [{"id": "tag-1", "name": "Urgent", "color": "#FF0000"}], + "dueAt": "2026-01-05T08:30:00Z" + }, + { + "public_id": "card-epoch-num", + "title": "EpochNum", + "labels": [{"public_id": "tag-2", "title": "Backend", "colorHex": "#00FF00"}], + "due": 1735689600000 + } + ] + }, + { + "publicId": "list-2", + "title": "Doing", + "data": [ + { + "id": "card-epoch-string", + "title": "EpochString", + "data": [{"id": "tag-3", "name": "Ops", "hex": "#0000FF"}], + "due_at": "1735689600123" + }, + { + "id": "card-invalid", + "name": "Invalid", + "labels": [], + "dueDate": "not-a-date" + } + ] + } + ] + } + } + } + """.trimIndent(), + ) + + val client = HttpKanbnApiClient() + + val result = client.getBoardDetail(server.baseUrl, "api-key", "board-1") + + assertTrue(result is BoardsApiResult.Success<*>) + val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail + assertEquals("board-1", detail.id) + assertEquals("Roadmap", detail.title) + assertEquals(2, detail.lists.size) + assertEquals("list-1", detail.lists[0].id) + assertEquals("Todo", detail.lists[0].title) + assertEquals("card-iso", detail.lists[0].cards[0].id) + assertEquals(Instant.parse("2026-01-05T08:30:00Z").toEpochMilli(), detail.lists[0].cards[0].dueAtEpochMillis) + assertEquals(1735689600000L, detail.lists[0].cards[1].dueAtEpochMillis) + assertEquals("tag-1", detail.lists[0].cards[0].tags[0].id) + assertEquals("Urgent", detail.lists[0].cards[0].tags[0].name) + assertEquals("#FF0000", detail.lists[0].cards[0].tags[0].colorHex) + assertEquals(1735689600123L, detail.lists[1].cards[0].dueAtEpochMillis) + assertNull(detail.lists[1].cards[1].dueAtEpochMillis) + } + } + + @Test + fun getBoardDetailParsesDirectRootObject() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/b-2", + status = 200, + responseBody = + """ + { + "id": "b-2", + "title": "Board Direct", + "lists": [ + { + "id": "l-1", + "title": "List", + "cards": [ + { + "id": "c-1", + "title": "Card", + "labels": [{"id": "t-1", "name": "Tag", "color": "#111111"}] + } + ] + } + ] + } + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "b-2") + + assertTrue(result is BoardsApiResult.Success<*>) + val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail + assertEquals("b-2", detail.id) + assertEquals("Board Direct", detail.title) + assertEquals(1, detail.lists.size) + assertEquals("l-1", detail.lists[0].id) + assertEquals("c-1", detail.lists[0].cards[0].id) + assertEquals("t-1", detail.lists[0].cards[0].tags[0].id) + } + } + + @Test + fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/b-1", + status = 200, + responseBody = """{"id":"b-1","title":"Board","lists":[]}""", + ) + server.register(path = "/api/v1/lists/l-1", status = 200, responseBody = "{}") + server.register(path = "/api/v1/cards/c-1", status = 200, responseBody = "{}") + server.register(path = "/api/v1/cards/c-2", status = 200, responseBody = "{}") + + val client = HttpKanbnApiClient() + val boardResult = client.getBoardDetail(server.baseUrl, "api-123", "b-1") + val renameResult = client.renameList(server.baseUrl, "api-123", "l-1", " New title ") + val moveResult = client.moveCard(server.baseUrl, "api-123", "c-1", "l-9") + val deleteResult = client.deleteCard(server.baseUrl, "api-123", "c-2") + + assertTrue(boardResult is BoardsApiResult.Success<*>) + assertTrue(renameResult is BoardsApiResult.Success<*>) + assertTrue(moveResult is BoardsApiResult.Success<*>) + assertTrue(deleteResult is BoardsApiResult.Success<*>) + + val boardRequest = server.findRequest("GET", "/api/v1/boards/b-1") + assertNotNull(boardRequest) + assertEquals("api-123", boardRequest?.apiKey) + + val renameRequest = server.findRequest("PATCH", "/api/v1/lists/l-1") + assertNotNull(renameRequest) + assertEquals("{\"name\":\"New title\"}", renameRequest?.body) + assertEquals("api-123", renameRequest?.apiKey) + + val moveRequest = server.findRequest("PATCH", "/api/v1/cards/c-1") + assertNotNull(moveRequest) + assertEquals("{\"listId\":\"l-9\"}", moveRequest?.body) + assertEquals("api-123", moveRequest?.apiKey) + + val deleteRequest = server.findRequest("DELETE", "/api/v1/cards/c-2") + assertNotNull(deleteRequest) + assertEquals("api-123", deleteRequest?.apiKey) + } + } + + @Test + fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/lists/l-error", + status = 400, + responseBody = """{"message":"List is locked"}""", + ) + server.register( + path = "/api/v1/lists/l-fallback", + status = 503, + responseBody = "{}", + ) + + val client = HttpKanbnApiClient() + val messageResult = client.renameList(server.baseUrl, "api", "l-error", "Name") + val fallbackResult = client.renameList(server.baseUrl, "api", "l-fallback", "Name") + + assertTrue(messageResult is BoardsApiResult.Failure) + assertEquals("List is locked", (messageResult as BoardsApiResult.Failure).message) + assertTrue(fallbackResult is BoardsApiResult.Failure) + assertEquals("Server error: 503", (fallbackResult as BoardsApiResult.Failure).message) + } + } + + private data class CapturedRequest( + val method: String, + val path: String, + val body: String, + val apiKey: String?, + ) + + private class TestServer : AutoCloseable { + private val requests = CopyOnWriteArrayList() + private val responses = mutableMapOf>() + private val running = AtomicBoolean(true) + private val serverSocket = ServerSocket().apply { + bind(InetSocketAddress("127.0.0.1", 0)) + } + private val executor = Executors.newSingleThreadExecutor() + + val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}" + + init { + executor.execute { + while (running.get()) { + val socket = try { + serverSocket.accept() + } catch (_: Throwable) { + if (!running.get()) { + break + } + continue + } + handle(socket) + } + } + } + + fun register(path: String, status: Int, responseBody: String) { + responses["GET $path"] = status to responseBody + responses["PATCH $path"] = status to responseBody + responses["DELETE $path"] = status to responseBody + responses["POST $path"] = status to responseBody + } + + fun findRequest(method: String, path: String): CapturedRequest? { + return requests.firstOrNull { it.method == method && it.path == path } + } + + private fun handle(socket: Socket) { + socket.use { s -> + s.soTimeout = 3_000 + val input = BufferedInputStream(s.getInputStream()) + val output = s.getOutputStream() + + val requestLine = readHttpLine(input).orEmpty() + if (requestLine.isBlank()) { + return + } + val parts = requestLine.split(" ") + val method = parts.getOrNull(0).orEmpty() + val path = parts.getOrNull(1).orEmpty() + + var apiKey: String? = null + var contentLength = 0 + var methodOverride: String? = null + while (true) { + val line = readHttpLine(input).orEmpty() + if (line.isBlank()) { + break + } + val separatorIndex = line.indexOf(':') + if (separatorIndex <= 0) { + continue + } + val headerName = line.substring(0, separatorIndex).trim().lowercase() + val headerValue = line.substring(separatorIndex + 1).trim() + if (headerName == "x-api-key") { + apiKey = headerValue + } else if (headerName == "x-http-method-override") { + methodOverride = headerValue + } else if (headerName == "content-length") { + contentLength = headerValue.toIntOrNull() ?: 0 + } + } + + val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0) + if (contentLength > 0) { + var total = 0 + while (total < contentLength) { + val read = input.read(bodyBytes, total, contentLength - total) + if (read <= 0) { + break + } + total += read + } + } + val body = String(bodyBytes) + val effectiveMethod = methodOverride ?: method + requests += CapturedRequest(method = effectiveMethod, path = path, body = body, apiKey = apiKey) + + val response = responses["$effectiveMethod $path"] ?: responses["$method $path"] ?: (404 to "") + writeResponse(output, response.first, response.second) + } + } + + private fun writeResponse(output: OutputStream, status: Int, body: String) { + val bytes = body.toByteArray() + val reason = when (status) { + 200 -> "OK" + 400 -> "Bad Request" + 404 -> "Not Found" + 503 -> "Service Unavailable" + else -> "Error" + } + val responseHeaders = + "HTTP/1.1 $status $reason\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: ${bytes.size}\r\n" + + "Connection: close\r\n\r\n" + output.write(responseHeaders.toByteArray()) + output.write(bytes) + output.flush() + } + + private fun readHttpLine(input: BufferedInputStream): String? { + val builder = StringBuilder() + while (true) { + val next = input.read() + if (next == -1) { + return if (builder.isEmpty()) null else builder.toString() + } + if (next == '\n'.code) { + if (builder.isNotEmpty() && builder.last() == '\r') { + builder.deleteCharAt(builder.length - 1) + } + return builder.toString() + } + builder.append(next.toChar()) + } + } + + override fun close() { + running.set(false) + serverSocket.close() + executor.shutdownNow() + executor.awaitTermination(3, TimeUnit.SECONDS) + } + } +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt index 2aabe86..7fbe893 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt @@ -8,6 +8,7 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail class BoardsRepositoryTest { @@ -250,5 +251,31 @@ class BoardsRepositoryTest { lastDeletedId = boardId return deleteBoardResult } + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } } } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt index a5d901e..799c73c 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt @@ -19,6 +19,7 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail @OptIn(ExperimentalCoroutinesApi::class) class BoardsViewModelTest { @@ -194,5 +195,31 @@ class BoardsViewModelTest { lastDeletedId = boardId return deleteBoardResult } + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } } } From a2a54523ef1b8d3434d5a3c3a20dc87c64b12ea0 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:23:04 -0400 Subject: [PATCH 04/16] chore: remove out-of-scope changes from task 2 --- AGENTS.md | 1 - .../kanbn4droid/app/BoardsFlowTest.kt | 27 ------------------ .../kanbn4droid/app/LoginFlowTest.kt | 28 ------------------- .../app/boards/BoardsRepositoryTest.kt | 27 ------------------ .../app/boards/BoardsViewModelTest.kt | 27 ------------------ 5 files changed, 110 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1809c0e..604415f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,6 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - Compile/target SDK: API 35. - Baseline tests: - JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`. - - JVM unit tests for board detail parsing and board/list/card API request mapping in `app/src/test/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt`. - JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`. - Instrumentation tests for login and boards flows in `app/src/androidTest/`. diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt index 55d51eb..fbd6c8e 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -24,7 +24,6 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.SessionStore -import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @@ -200,31 +199,5 @@ class BoardsFlowTest { boards.removeAll { it.id == boardId } return BoardsApiResult.Success(Unit) } - - override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun renameList( - baseUrl: String, - apiKey: String, - listId: String, - newTitle: String, - ): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun moveCard( - baseUrl: String, - apiKey: String, - cardId: String, - targetListId: String, - ): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } } } diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt index 57a8e93..67350a9 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt @@ -22,8 +22,6 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.SessionStore -import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail -import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @RunWith(AndroidJUnit4::class) class LoginFlowTest { @@ -202,31 +200,5 @@ class LoginFlowTest { private val result: AuthResult, ) : KanbnApiClient { override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result - - override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun renameList( - baseUrl: String, - apiKey: String, - listId: String, - newTitle: String, - ): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun moveCard( - baseUrl: String, - apiKey: String, - cardId: String, - targetListId: String, - ): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } } } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt index 7fbe893..2aabe86 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt @@ -8,7 +8,6 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.SessionStore -import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail class BoardsRepositoryTest { @@ -251,31 +250,5 @@ class BoardsRepositoryTest { lastDeletedId = boardId return deleteBoardResult } - - override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun renameList( - baseUrl: String, - apiKey: String, - listId: String, - newTitle: String, - ): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun moveCard( - baseUrl: String, - apiKey: String, - cardId: String, - targetListId: String, - ): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } } } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt index 799c73c..a5d901e 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt @@ -19,7 +19,6 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.SessionStore -import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail @OptIn(ExperimentalCoroutinesApi::class) class BoardsViewModelTest { @@ -195,31 +194,5 @@ class BoardsViewModelTest { lastDeletedId = boardId return deleteBoardResult } - - override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun renameList( - baseUrl: String, - apiKey: String, - listId: String, - newTitle: String, - ): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun moveCard( - baseUrl: String, - apiKey: String, - cardId: String, - targetListId: String, - ): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } - - override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { - return BoardsApiResult.Failure("Not used in this test") - } } } From 9602b7959f503e89bcb2bcca628f6a46439f4abc Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:25:24 -0400 Subject: [PATCH 05/16] fix: support plain data board wrapper parsing --- .../kanbn4droid/app/auth/KanbnApiClient.kt | 1 + ...ttpKanbnApiClientBoardDetailParsingTest.kt | 99 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index ab8c586..e416074 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -487,6 +487,7 @@ class HttpKanbnApiClient : KanbnApiClient { ?: return BoardDetail(id = fallbackId, title = "Board", lists = emptyList()) val data = root["data"] as? Map<*, *> val board = (data?.get("board") as? Map<*, *>) + ?: data ?: (root["board"] as? Map<*, *>) ?: root diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt index 5352c43..d5d9b4c 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt @@ -141,6 +141,51 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun getBoardDetailParsesPlainDataWrapperWithoutBoardKey() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/data-only", + status = 200, + responseBody = + """ + { + "data": { + "id": "data-only", + "name": "Wrapped board", + "lists": [ + { + "public_id": "list-9", + "name": "Queue", + "cards": [ + { + "publicId": "card-99", + "name": "Card in data wrapper", + "labels": [ + {"public_id": "tag-77", "title": "Infra", "color": "#ABCDEF"} + ] + } + ] + } + ] + } + } + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "data-only") + + assertTrue(result is BoardsApiResult.Success<*>) + val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail + assertEquals("data-only", detail.id) + assertEquals("Wrapped board", detail.title) + assertEquals(1, detail.lists.size) + assertEquals("list-9", detail.lists[0].id) + assertEquals("card-99", detail.lists[0].cards[0].id) + assertEquals("tag-77", detail.lists[0].cards[0].tags[0].id) + } + } + @Test fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest { TestServer().use { server -> @@ -209,6 +254,56 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun moveCardFailureUsesServerMessageAndFallback() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/cards/c-msg", + status = 409, + responseBody = """{"error":"Card cannot be moved"}""", + ) + server.register( + path = "/api/v1/cards/c-fallback", + status = 500, + responseBody = "{}", + ) + + val client = HttpKanbnApiClient() + val messageResult = client.moveCard(server.baseUrl, "api", "c-msg", "l-1") + val fallbackResult = client.moveCard(server.baseUrl, "api", "c-fallback", "l-1") + + assertTrue(messageResult is BoardsApiResult.Failure) + assertEquals("Card cannot be moved", (messageResult as BoardsApiResult.Failure).message) + assertTrue(fallbackResult is BoardsApiResult.Failure) + assertEquals("Server error: 500", (fallbackResult as BoardsApiResult.Failure).message) + } + } + + @Test + fun deleteCardFailureUsesServerMessageAndFallback() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/cards/c-del-msg", + status = 403, + responseBody = """{"detail":"No permission to delete card"}""", + ) + server.register( + path = "/api/v1/cards/c-del-fallback", + status = 502, + responseBody = "[]", + ) + + val client = HttpKanbnApiClient() + val messageResult = client.deleteCard(server.baseUrl, "api", "c-del-msg") + val fallbackResult = client.deleteCard(server.baseUrl, "api", "c-del-fallback") + + assertTrue(messageResult is BoardsApiResult.Failure) + assertEquals("No permission to delete card", (messageResult as BoardsApiResult.Failure).message) + assertTrue(fallbackResult is BoardsApiResult.Failure) + assertEquals("Server error: 502", (fallbackResult as BoardsApiResult.Failure).message) + } + } + private data class CapturedRequest( val method: String, val path: String, @@ -316,7 +411,11 @@ class HttpKanbnApiClientBoardDetailParsingTest { val reason = when (status) { 200 -> "OK" 400 -> "Bad Request" + 403 -> "Forbidden" + 409 -> "Conflict" 404 -> "Not Found" + 500 -> "Internal Server Error" + 502 -> "Bad Gateway" 503 -> "Service Unavailable" else -> "Error" } From e7ad14902dc11c8d2a3c3da217c692e1236ad51b Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:27:45 -0400 Subject: [PATCH 06/16] fix: fail on malformed board detail responses --- .../kanbn4droid/app/auth/KanbnApiClient.kt | 36 ++++++++++++------ ...ttpKanbnApiClientBoardDetailParsingTest.kt | 37 +++++++++++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index e416074..ebd8bba 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -227,7 +227,9 @@ class HttpKanbnApiClient : KanbnApiClient { apiKey = apiKey, ) { code, body -> if (code in 200..299) { - BoardsApiResult.Success(parseBoardDetail(body, boardId)) + parseBoardDetail(body, boardId) + ?.let { BoardsApiResult.Success(it) } + ?: BoardsApiResult.Failure("Malformed board detail response.") } else { BoardsApiResult.Failure(serverMessage(body, code)) } @@ -482,9 +484,13 @@ class HttpKanbnApiClient : KanbnApiClient { return workspaces } - private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail { + private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail? { + if (body.isBlank()) { + return BoardDetail(id = fallbackId, title = "Board", lists = emptyList()) + } + val root = parseJsonObject(body) - ?: return BoardDetail(id = fallbackId, title = "Board", lists = emptyList()) + ?: return null val data = root["data"] as? Map<*, *> val board = (data?.get("board") as? Map<*, *>) ?: data @@ -592,15 +598,21 @@ class HttpKanbnApiClient : KanbnApiClient { private fun jsonEscape(value: String): String { val builder = StringBuilder() value.forEach { ch -> - when (ch) { - '\\' -> builder.append("\\\\") - '"' -> builder.append("\\\"") - '\b' -> builder.append("\\b") - '\u000C' -> builder.append("\\f") - '\n' -> builder.append("\\n") - '\r' -> builder.append("\\r") - '\t' -> builder.append("\\t") - else -> builder.append(ch) + if (ch.code in 0x00..0x1F) { + when (ch) { + '\b' -> builder.append("\\b") + '\u000C' -> builder.append("\\f") + '\n' -> builder.append("\\n") + '\r' -> builder.append("\\r") + '\t' -> builder.append("\\t") + else -> builder.append("\\u%04x".format(ch.code)) + } + } else { + when (ch) { + '\\' -> builder.append("\\\\") + '"' -> builder.append("\\\"") + else -> builder.append(ch) + } } } return builder.toString() diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt index d5d9b4c..fb80d73 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt @@ -186,6 +186,25 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun getBoardDetailFailsOnMalformedJsonPayload() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/malformed", + status = 200, + responseBody = "{\"data\": {\"id\": \"broken\"", + ) + + val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "malformed") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals( + "Malformed board detail response.", + (result as BoardsApiResult.Failure).message, + ) + } + } + @Test fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest { TestServer().use { server -> @@ -229,6 +248,24 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun renameListEscapesControlCharactersInRequestBody() = runTest { + TestServer().use { server -> + server.register(path = "/api/v1/lists/l-esc", status = 200, responseBody = "{}") + + val raw = "name\u0000line\u001f\n\tend" + val result = HttpKanbnApiClient().renameList(server.baseUrl, "api", "l-esc", raw) + + assertTrue(result is BoardsApiResult.Success<*>) + val request = server.findRequest("PATCH", "/api/v1/lists/l-esc") + assertNotNull(request) + assertEquals( + "{\"name\":\"name\\u0000line\\u001f\\n\\tend\"}", + request?.body, + ) + } + } + @Test fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest { TestServer().use { server -> From c56b9d042a318a51c4989229e88098c0d399aab9 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:32:55 -0400 Subject: [PATCH 07/16] feat: implement board detail repository and mutation aggregation --- .../app/boarddetail/BoardDetailRepository.kt | 197 ++++++++ .../boarddetail/BoardDetailRepositoryTest.kt | 431 ++++++++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt new file mode 100644 index 0000000..0e9dc6e --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt @@ -0,0 +1,197 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +class BoardDetailRepository( + private val sessionStore: SessionStore, + private val apiKeyStore: ApiKeyStore, + private val apiClient: KanbnApiClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + suspend fun getBoardDetail(boardId: String): BoardsApiResult { + val normalizedBoardId = boardId.trim() + if (normalizedBoardId.isBlank()) { + return BoardsApiResult.Failure("Board id is required") + } + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return sessionResult + } + + return apiClient.getBoardDetail( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + boardId = normalizedBoardId, + ) + } + + suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { + val normalizedListId = listId.trim() + if (normalizedListId.isBlank()) { + return BoardsApiResult.Failure("List id is required") + } + + val normalizedTitle = newTitle.trim() + if (normalizedTitle.isBlank()) { + return BoardsApiResult.Failure("List title is required") + } + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return sessionResult + } + + return apiClient.renameList( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + listId = normalizedListId, + newTitle = normalizedTitle, + ) + } + + suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + val normalizedTargetListId = targetListId.trim() + if (normalizedTargetListId.isBlank()) { + return CardBatchMutationResult.Failure("Target list id is required") + } + + val normalizedCardIds = normalizeCardIds(cardIds) + if (normalizedCardIds.isEmpty()) { + return CardBatchMutationResult.Failure("At least one card id is required") + } + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message) + } + + val failuresByCardId = linkedMapOf() + normalizedCardIds.forEach { cardId -> + when ( + val result = apiClient.moveCard( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = cardId, + targetListId = normalizedTargetListId, + ) + ) { + is BoardsApiResult.Success -> Unit + is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message + } + } + + return aggregateBatchMutationResult( + normalizedCardIds = normalizedCardIds, + failuresByCardId = failuresByCardId, + partialMessage = "Some cards could not be moved. Please try again.", + ) + } + + suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + val normalizedCardIds = normalizeCardIds(cardIds) + if (normalizedCardIds.isEmpty()) { + return CardBatchMutationResult.Failure("At least one card id is required") + } + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message) + } + + val failuresByCardId = linkedMapOf() + normalizedCardIds.forEach { cardId -> + when (val result = apiClient.deleteCard(session.baseUrl, session.apiKey, cardId)) { + is BoardsApiResult.Success -> Unit + is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message + } + } + + return aggregateBatchMutationResult( + normalizedCardIds = normalizedCardIds, + failuresByCardId = failuresByCardId, + partialMessage = "Some cards could not be deleted. Please try again.", + ) + } + + private fun normalizeCardIds(cardIds: Collection): List { + return cardIds.map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + } + + private fun aggregateBatchMutationResult( + normalizedCardIds: List, + failuresByCardId: Map, + partialMessage: String, + ): CardBatchMutationResult { + if (failuresByCardId.isEmpty()) { + return CardBatchMutationResult.Success + } + + if (failuresByCardId.size == normalizedCardIds.size) { + val firstFailureMessage = normalizedCardIds + .asSequence() + .mapNotNull { failuresByCardId[it] } + .firstOrNull() + ?.trim() + .orEmpty() + .ifBlank { "Unknown error" } + return CardBatchMutationResult.Failure(firstFailureMessage) + } + + return CardBatchMutationResult.PartialSuccess( + failedCardIds = failuresByCardId.keys.toSet(), + message = partialMessage, + ) + } + + private suspend fun session(): BoardsApiResult { + val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } + ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + val apiKey = withContext(ioDispatcher) { + apiKeyStore.getApiKey(baseUrl) + }.getOrNull()?.takeIf { it.isNotBlank() } + ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + + val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) { + is BoardsApiResult.Success -> workspaceResult.value + is BoardsApiResult.Failure -> return workspaceResult + } + + return BoardsApiResult.Success( + SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId), + ) + } + + private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult { + val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() } + if (storedWorkspaceId != null) { + return BoardsApiResult.Success(storedWorkspaceId) + } + + return when (val workspacesResult = apiClient.listWorkspaces(baseUrl, apiKey)) { + is BoardsApiResult.Success -> { + val first = workspacesResult.value.firstOrNull()?.id + ?: return BoardsApiResult.Failure("No workspaces available for this account.") + sessionStore.saveWorkspaceId(first) + BoardsApiResult.Success(first) + } + + is BoardsApiResult.Failure -> workspacesResult + } + } + + private data class SessionSnapshot( + val baseUrl: String, + val apiKey: String, + @Suppress("unused") + val workspaceId: String, + ) +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt new file mode 100644 index 0000000..27ec121 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt @@ -0,0 +1,431 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.AuthResult +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boards.BoardSummary +import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult +import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary + +class BoardDetailRepositoryTest { + + @Test + fun getBoardDetailUsesStoredWorkspaceWhenPresent() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + boardDetailResult = BoardsApiResult.Success(sampleBoardDetail()) + } + val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-stored") + val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient) + + val result = repository.getBoardDetail("board-1") + + assertTrue(result is BoardsApiResult.Success<*>) + assertEquals(0, apiClient.listWorkspacesCalls) + assertEquals("board-1", apiClient.lastBoardId) + } + + @Test + fun getBoardDetailFetchesAndPersistsWorkspaceWhenMissing() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + boardDetailResult = BoardsApiResult.Success(sampleBoardDetail()) + } + val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/") + val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient) + + val result = repository.getBoardDetail("board-1") + + assertTrue(result is BoardsApiResult.Success<*>) + assertEquals(1, apiClient.listWorkspacesCalls) + assertEquals("ws-1", sessionStore.getWorkspaceId()) + assertEquals("board-1", apiClient.lastBoardId) + } + + @Test + fun getBoardDetailFailsWhenNoWorkspacesAvailable() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + workspacesResult = BoardsApiResult.Success(emptyList()) + } + val repository = createRepository( + sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/"), + apiClient = apiClient, + ) + + val result = repository.getBoardDetail("board-1") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message) + } + + @Test + fun getBoardDetailPropagatesApiFailureMessageUnchanged() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + boardDetailResult = BoardsApiResult.Failure("Server says no") + } + val repository = createRepository( + sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"), + apiClient = apiClient, + ) + + val result = repository.getBoardDetail("board-1") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("Server says no", (result as BoardsApiResult.Failure).message) + } + + @Test + fun renameListPropagatesApiFailureMessageUnchanged() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + renameListResult = BoardsApiResult.Failure("List cannot be renamed") + } + val repository = createRepository( + sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"), + apiClient = apiClient, + ) + + val result = repository.renameList("list-1", "New title") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("List cannot be renamed", (result as BoardsApiResult.Failure).message) + assertEquals("list-1", apiClient.lastListId) + assertEquals("New title", apiClient.lastListTitle) + } + + @Test + fun getBoardDetailValidatesBoardId() = runTest { + val repository = createRepository() + + val result = repository.getBoardDetail(" ") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("Board id is required", (result as BoardsApiResult.Failure).message) + } + + @Test + fun renameListValidatesListId() = runTest { + val repository = createRepository() + + val result = repository.renameList(" ", "Some title") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("List id is required", (result as BoardsApiResult.Failure).message) + } + + @Test + fun renameListValidatesTitle() = runTest { + val repository = createRepository() + + val result = repository.renameList("list-1", " ") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("List title is required", (result as BoardsApiResult.Failure).message) + } + + @Test + fun moveCardsValidatesTargetListId() = runTest { + val repository = createRepository() + + val result = repository.moveCards(cardIds = listOf("card-1"), targetListId = " ") + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("Target list id is required", (result as CardBatchMutationResult.Failure).message) + } + + @Test + fun moveCardsValidatesCardIds() = runTest { + val repository = createRepository() + + val result = repository.moveCards(cardIds = listOf(" ", ""), targetListId = "list-2") + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("At least one card id is required", (result as CardBatchMutationResult.Failure).message) + } + + @Test + fun deleteCardsValidatesCardIds() = runTest { + val repository = createRepository() + + val result = repository.deleteCards(cardIds = listOf(" ", "")) + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("At least one card id is required", (result as CardBatchMutationResult.Failure).message) + } + + @Test + fun moveCardsReturnsSuccessWhenAllMutationsSucceed() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + moveOutcomes = mapOf( + "card-1" to BoardsApiResult.Success(Unit), + "card-2" to BoardsApiResult.Success(Unit), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.moveCards( + cardIds = listOf(" card-1 ", "", "card-1", "card-2"), + targetListId = " list-target ", + ) + + assertEquals(CardBatchMutationResult.Success, result) + assertEquals(listOf("card-1", "card-2"), apiClient.movedCardIds) + assertEquals("list-target", apiClient.lastMoveTargetListId) + } + + @Test + fun moveCardsReturnsPartialSuccessWhenSomeMutationsFail() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + moveOutcomes = mapOf( + "card-1" to BoardsApiResult.Success(Unit), + "card-2" to BoardsApiResult.Failure("Cannot move"), + "card-3" to BoardsApiResult.Success(Unit), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.moveCards( + cardIds = listOf("card-1", " card-2 ", "card-3", "card-2"), + targetListId = "list-target", + ) + + assertTrue(result is CardBatchMutationResult.PartialSuccess) + assertEquals(setOf("card-2"), (result as CardBatchMutationResult.PartialSuccess).failedCardIds) + assertEquals("Some cards could not be moved. Please try again.", result.message) + assertEquals(listOf("card-1", "card-2", "card-3"), apiClient.movedCardIds) + } + + @Test + fun moveCardsReturnsFailureWithFirstNormalizedMessageWhenAllFail() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + moveOutcomes = mapOf( + "card-2" to BoardsApiResult.Failure(" "), + "card-1" to BoardsApiResult.Failure("Second failure"), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.moveCards( + cardIds = listOf(" card-2 ", "card-1", "card-2"), + targetListId = "list-target", + ) + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("Unknown error", (result as CardBatchMutationResult.Failure).message) + assertEquals(listOf("card-2", "card-1"), apiClient.movedCardIds) + } + + @Test + fun deleteCardsReturnsSuccessWhenAllMutationsSucceed() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + deleteOutcomes = mapOf( + "card-1" to BoardsApiResult.Success(Unit), + "card-2" to BoardsApiResult.Success(Unit), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.deleteCards(cardIds = listOf("card-1", " card-2 ", "card-1")) + + assertEquals(CardBatchMutationResult.Success, result) + assertEquals(listOf("card-1", "card-2"), apiClient.deletedCardIds) + } + + @Test + fun deleteCardsReturnsPartialSuccessWhenSomeMutationsFail() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + deleteOutcomes = mapOf( + "card-1" to BoardsApiResult.Success(Unit), + "card-2" to BoardsApiResult.Failure("Cannot delete"), + "card-3" to BoardsApiResult.Success(Unit), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.deleteCards(cardIds = listOf("card-1", " card-2 ", "card-3", "card-2")) + + assertTrue(result is CardBatchMutationResult.PartialSuccess) + assertEquals(setOf("card-2"), (result as CardBatchMutationResult.PartialSuccess).failedCardIds) + assertEquals("Some cards could not be deleted. Please try again.", result.message) + assertEquals(listOf("card-1", "card-2", "card-3"), apiClient.deletedCardIds) + } + + @Test + fun deleteCardsReturnsFailureWithFirstNormalizedMessageWhenAllFail() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + deleteOutcomes = mapOf( + "card-2" to BoardsApiResult.Failure("Delete failed first"), + "card-1" to BoardsApiResult.Failure("Delete failed second"), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.deleteCards(cardIds = listOf(" card-2 ", "card-1", "card-2")) + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("Delete failed first", (result as CardBatchMutationResult.Failure).message) + assertEquals(listOf("card-2", "card-1"), apiClient.deletedCardIds) + } + + private fun createRepository( + sessionStore: InMemorySessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"), + apiClient: FakeBoardDetailApiClient = FakeBoardDetailApiClient(), + apiKeyStore: InMemoryApiKeyStore = InMemoryApiKeyStore("api-key"), + ): BoardDetailRepository { + return BoardDetailRepository( + sessionStore = sessionStore, + apiKeyStore = apiKeyStore, + apiClient = apiClient, + ) + } + + private class InMemorySessionStore( + private var baseUrl: String?, + private var workspaceId: String? = null, + ) : SessionStore { + override fun getBaseUrl(): String? = baseUrl + + override fun saveBaseUrl(url: String) { + baseUrl = url + } + + override fun getWorkspaceId(): String? = workspaceId + + override fun saveWorkspaceId(workspaceId: String) { + this.workspaceId = workspaceId + } + + override fun clearBaseUrl() { + baseUrl = null + } + + override fun clearWorkspaceId() { + workspaceId = null + } + } + + private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore { + override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { + this.apiKey = apiKey + return Result.success(Unit) + } + + override suspend fun getApiKey(baseUrl: String): Result = Result.success(apiKey) + + override suspend fun invalidateApiKey(baseUrl: String): Result { + apiKey = null + return Result.success(Unit) + } + } + + private class FakeBoardDetailApiClient : KanbnApiClient { + var workspacesResult: BoardsApiResult> = + BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + var boardDetailResult: BoardsApiResult = BoardsApiResult.Success(sampleBoardDetail()) + var renameListResult: BoardsApiResult = BoardsApiResult.Success(Unit) + var moveOutcomes: Map> = emptyMap() + var deleteOutcomes: Map> = emptyMap() + + var listWorkspacesCalls: Int = 0 + var lastBoardId: String? = null + var lastListId: String? = null + var lastListTitle: String? = null + var movedCardIds: MutableList = mutableListOf() + var deletedCardIds: MutableList = mutableListOf() + var lastMoveTargetListId: String? = null + + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success + + override suspend fun listWorkspaces( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + listWorkspacesCalls += 1 + return workspacesResult + } + + override suspend fun getBoardDetail( + baseUrl: String, + apiKey: String, + boardId: String, + ): BoardsApiResult { + lastBoardId = boardId + return boardDetailResult + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + lastListId = listId + lastListTitle = newTitle + return renameListResult + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + movedCardIds += cardId + lastMoveTargetListId = targetListId + return moveOutcomes[cardId] ?: BoardsApiResult.Success(Unit) + } + + override suspend fun deleteCard( + baseUrl: String, + apiKey: String, + cardId: String, + ): BoardsApiResult { + deletedCardIds += cardId + return deleteOutcomes[cardId] ?: BoardsApiResult.Success(Unit) + } + + override suspend fun listBoards( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { + return BoardsApiResult.Success(emptyList()) + } + + override suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { + return BoardsApiResult.Success(emptyList()) + } + + override suspend fun createBoard( + baseUrl: String, + apiKey: String, + workspaceId: String, + name: String, + templateId: String?, + ): BoardsApiResult { + return BoardsApiResult.Success(BoardSummary("new", name)) + } + + override suspend fun deleteBoard( + baseUrl: String, + apiKey: String, + boardId: String, + ): BoardsApiResult { + return BoardsApiResult.Success(Unit) + } + } + + private companion object { + fun sampleBoardDetail(): BoardDetail { + return BoardDetail(id = "board-1", title = "Board", lists = emptyList()) + } + } +} From 2c40892906d2dcb22a83a132ccc60f5fc5ca46d2 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:36:06 -0400 Subject: [PATCH 08/16] test: add missing board detail repository edge coverage --- .../boarddetail/BoardDetailRepositoryTest.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt index 27ec121..b7d1352 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt @@ -47,6 +47,26 @@ class BoardDetailRepositoryTest { assertEquals("board-1", apiClient.lastBoardId) } + @Test + fun getBoardDetailReusesPersistedWorkspaceAfterFirstFetch() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + boardDetailResult = BoardsApiResult.Success(sampleBoardDetail()) + } + val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/") + val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient) + + val firstResult = repository.getBoardDetail("board-1") + apiClient.workspacesResult = BoardsApiResult.Failure("Should not be called") + val secondResult = repository.getBoardDetail("board-2") + + assertTrue(firstResult is BoardsApiResult.Success<*>) + assertTrue(secondResult is BoardsApiResult.Success<*>) + assertEquals("ws-1", sessionStore.getWorkspaceId()) + assertEquals(1, apiClient.listWorkspacesCalls) + assertEquals("board-2", apiClient.lastBoardId) + } + @Test fun getBoardDetailFailsWhenNoWorkspacesAvailable() = runTest { val apiClient = FakeBoardDetailApiClient().apply { @@ -271,6 +291,23 @@ class BoardDetailRepositoryTest { assertEquals(listOf("card-2", "card-1"), apiClient.deletedCardIds) } + @Test + fun deleteCardsReturnsUnknownErrorWhenAllFailAndFirstMessageIsBlank() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + deleteOutcomes = mapOf( + "card-2" to BoardsApiResult.Failure(" "), + "card-1" to BoardsApiResult.Failure("Delete failed second"), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.deleteCards(cardIds = listOf(" card-2 ", "card-1", "card-2")) + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("Unknown error", (result as CardBatchMutationResult.Failure).message) + assertEquals(listOf("card-2", "card-1"), apiClient.deletedCardIds) + } + private fun createRepository( sessionStore: InMemorySessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"), apiClient: FakeBoardDetailApiClient = FakeBoardDetailApiClient(), From 89537a57b7fc113caa024f9320d8cb2f56413717 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:43:48 -0400 Subject: [PATCH 09/16] feat: add board detail viewmodel state and selection logic --- .../app/boarddetail/BoardDetailViewModel.kt | 368 ++++++++++++ .../boarddetail/BoardDetailViewModelTest.kt | 532 ++++++++++++++++++ 2 files changed, 900 insertions(+) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt new file mode 100644 index 0000000..3540ec7 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt @@ -0,0 +1,368 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +data class BoardDetailUiState( + val isInitialLoading: Boolean = false, + val isRefreshing: Boolean = false, + val isMutating: Boolean = false, + val boardDetail: BoardDetail? = null, + val fullScreenErrorMessage: String? = null, + val currentPageIndex: Int = 0, + val selectedCardIds: Set = emptySet(), + val editingListId: String? = null, + val editingListTitle: String = "", +) + +sealed interface BoardDetailUiEvent { + data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent + data class ShowServerError(val message: String) : BoardDetailUiEvent + data class ShowWarning(val message: String) : BoardDetailUiEvent +} + +interface BoardDetailDataSource { + suspend fun getBoardDetail(boardId: String): BoardsApiResult + suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult + suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult + suspend fun renameList(listId: String, newTitle: String): BoardsApiResult +} + +internal class BoardDetailRepositoryDataSource( + private val repository: BoardDetailRepository, +) : BoardDetailDataSource { + override suspend fun getBoardDetail(boardId: String): BoardsApiResult { + return repository.getBoardDetail(boardId) + } + + override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + return repository.moveCards(cardIds, targetListId) + } + + override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + return repository.deleteCards(cardIds) + } + + override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { + return repository.renameList(listId, newTitle) + } +} + +class BoardDetailViewModel( + private val boardId: String, + private val repository: BoardDetailDataSource, +) : ViewModel() { + private val _uiState = MutableStateFlow(BoardDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + fun loadBoardDetail() { + fetchBoardDetail(initial = true) + } + + fun retryLoad() { + fetchBoardDetail(initial = true) + } + + fun refreshBoardDetail() { + fetchBoardDetail(initial = false, refresh = true) + } + + fun setCurrentPage(pageIndex: Int) { + _uiState.update { + it.copy(currentPageIndex = clampPageIndex(detail = it.boardDetail, pageIndex = pageIndex)) + } + } + + fun selectAllOnCurrentPage() { + val current = _uiState.value + val pageCards = current.boardDetail + ?.lists + ?.getOrNull(current.currentPageIndex) + ?.cards + .orEmpty() + .map { it.id } + .toSet() + if (pageCards.isEmpty()) { + return + } + + _uiState.update { + it.copy(selectedCardIds = it.selectedCardIds + pageCards) + } + } + + fun onCardLongPressed(cardId: String) { + toggleCardSelection(cardId) + } + + fun onCardTapped(cardId: String) { + val hasSelection = _uiState.value.selectedCardIds.isNotEmpty() + if (hasSelection) { + toggleCardSelection(cardId) + return + } + + viewModelScope.launch { + _events.emit(BoardDetailUiEvent.NavigateToCardPlaceholder(cardId)) + } + } + + fun onBackPressed(): Boolean { + if (_uiState.value.selectedCardIds.isEmpty()) { + return false + } + + _uiState.update { it.copy(selectedCardIds = emptySet()) } + return true + } + + fun moveSelectedCards(targetListId: String) { + runMutation { selectedIds -> repository.moveCards(selectedIds, targetListId) } + } + + fun deleteSelectedCards() { + runMutation(repository::deleteCards) + } + + fun startEditingList(listId: String) { + val list = _uiState.value.boardDetail?.lists?.firstOrNull { it.id == listId } ?: return + _uiState.update { + it.copy( + editingListId = list.id, + editingListTitle = list.title, + ) + } + } + + fun updateEditingTitle(title: String) { + _uiState.update { it.copy(editingListTitle = title) } + } + + fun submitRenameList() { + val snapshot = _uiState.value + val editingListId = snapshot.editingListId ?: return + val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return + val trimmedTitle = snapshot.editingListTitle.trim() + + if (trimmedTitle == currentList.title.trim()) { + _uiState.update { it.copy(editingListId = null, editingListTitle = "") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isMutating = true) } + when (val result = repository.renameList(editingListId, trimmedTitle)) { + is BoardsApiResult.Success -> { + _uiState.update { it.copy(editingListId = null, editingListTitle = "") } + val reloadFailureMessage = tryReloadDetailAndReconcile() + _uiState.update { it.copy(isMutating = false) } + if (reloadFailureMessage != null) { + _events.emit( + BoardDetailUiEvent.ShowWarning( + "Changes applied, but refresh failed. Pull to refresh.", + ), + ) + } + } + + is BoardsApiResult.Failure -> { + _uiState.update { it.copy(isMutating = false) } + _events.emit(BoardDetailUiEvent.ShowServerError(result.message)) + } + } + } + } + + private fun fetchBoardDetail(initial: Boolean, refresh: Boolean = false) { + if (_uiState.value.isMutating) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + isInitialLoading = initial && it.boardDetail == null, + isRefreshing = refresh, + fullScreenErrorMessage = if (initial && it.boardDetail == null) null else it.fullScreenErrorMessage, + ) + } + + when (val result = repository.getBoardDetail(boardId)) { + is BoardsApiResult.Success -> { + _uiState.update { + reconcileWithNewDetail(it, result.value).copy( + isInitialLoading = false, + isRefreshing = false, + fullScreenErrorMessage = null, + ) + } + } + + is BoardsApiResult.Failure -> { + _uiState.update { + if (it.boardDetail == null) { + it.copy( + isInitialLoading = false, + isRefreshing = false, + fullScreenErrorMessage = result.message, + ) + } else { + it.copy( + isInitialLoading = false, + isRefreshing = false, + ) + } + } + if (_uiState.value.boardDetail != null) { + _events.emit(BoardDetailUiEvent.ShowServerError(result.message)) + } + } + } + } + } + + private fun runMutation( + mutation: suspend (Set) -> CardBatchMutationResult, + ) { + val preMutation = _uiState.value + val selectedIds = preMutation.selectedCardIds + if (selectedIds.isEmpty()) { + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isMutating = true) } + + when (val result = mutation(selectedIds)) { + is CardBatchMutationResult.Success -> { + _uiState.update { it.copy(selectedCardIds = emptySet()) } + val reloadFailureMessage = tryReloadDetailAndReconcile() + _uiState.update { it.copy(isMutating = false) } + if (reloadFailureMessage != null) { + _events.emit( + BoardDetailUiEvent.ShowWarning( + "Changes applied, but refresh failed. Pull to refresh.", + ), + ) + } + } + + is CardBatchMutationResult.PartialSuccess -> { + val reloadFailureMessage = tryReloadDetailAndReconcile() + if (reloadFailureMessage == null) { + val visibleIds = allVisibleCardIds(_uiState.value.boardDetail) + _uiState.update { + it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds)) + } + _events.emit(BoardDetailUiEvent.ShowWarning(result.message)) + } else { + _uiState.update { + it.copy( + selectedCardIds = preMutation.selectedCardIds, + currentPageIndex = preMutation.currentPageIndex, + ) + } + _events.emit( + BoardDetailUiEvent.ShowWarning( + "Some changes were applied, but refresh failed. Pull to refresh.", + ), + ) + } + _uiState.update { it.copy(isMutating = false) } + } + + is CardBatchMutationResult.Failure -> { + _uiState.update { + it.copy( + isMutating = false, + selectedCardIds = preMutation.selectedCardIds, + currentPageIndex = preMutation.currentPageIndex, + ) + } + _events.emit(BoardDetailUiEvent.ShowServerError(result.message)) + } + } + } + } + + private suspend fun tryReloadDetailAndReconcile(): String? { + return when (val result = repository.getBoardDetail(boardId)) { + is BoardsApiResult.Success -> { + _uiState.update { reconcileWithNewDetail(it, result.value) } + null + } + + is BoardsApiResult.Failure -> result.message + } + } + + private fun toggleCardSelection(cardId: String) { + _uiState.update { + val next = it.selectedCardIds.toMutableSet() + if (!next.add(cardId)) { + next.remove(cardId) + } + it.copy(selectedCardIds = next) + } + } + + private fun reconcileWithNewDetail(current: BoardDetailUiState, detail: BoardDetail): BoardDetailUiState { + val clampedPage = clampPageIndex(detail, current.currentPageIndex) + val visibleIds = allVisibleCardIds(detail) + val prunedSelection = current.selectedCardIds.intersect(visibleIds) + val hasEditedList = current.editingListId?.let { id -> detail.lists.any { it.id == id } } ?: false + + return current.copy( + boardDetail = detail, + currentPageIndex = clampedPage, + selectedCardIds = prunedSelection, + editingListId = if (hasEditedList) current.editingListId else null, + editingListTitle = if (hasEditedList) current.editingListTitle else "", + ) + } + + private fun clampPageIndex(detail: BoardDetail?, pageIndex: Int): Int { + val lastIndex = detail?.lists?.lastIndex ?: -1 + if (lastIndex < 0) { + return 0 + } + return pageIndex.coerceIn(0, lastIndex) + } + + private fun allVisibleCardIds(detail: BoardDetail?): Set { + return detail?.lists + .orEmpty() + .flatMap { list -> list.cards } + .map { card -> card.id } + .toSet() + } + + class Factory( + private val boardId: String, + private val repository: BoardDetailRepository, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) { + return BoardDetailViewModel( + boardId = boardId, + repository = BoardDetailRepositoryDataSource(repository), + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } + } +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt new file mode 100644 index 0000000..e52465c --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt @@ -0,0 +1,532 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +@OptIn(ExperimentalCoroutinesApi::class) +class BoardDetailViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + kotlinx.coroutines.Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + kotlinx.coroutines.Dispatchers.resetMain() + } + + @Test + fun selectAllIsAdditiveAcrossPages() = runTest { + val viewModel = newLoadedViewModel( + scope = this, + repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithTwoLists()))), + ), + ) + + viewModel.selectAllOnCurrentPage() + viewModel.setCurrentPage(1) + viewModel.selectAllOnCurrentPage() + + assertEquals(setOf("card-1", "card-2", "card-3"), viewModel.uiState.value.selectedCardIds) + } + + @Test + fun backPressClearsSelectionWhenSelectionActive() = runTest { + val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists()) + + viewModel.onCardLongPressed("card-1") + + assertTrue(viewModel.onBackPressed()) + assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty()) + } + + @Test + fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest { + val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists()) + val eventDeferred = async { viewModel.events.first() } + + viewModel.onCardTapped("card-1") + advanceUntilIdle() + + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.NavigateToCardPlaceholder) + assertEquals("card-1", (event as BoardDetailUiEvent.NavigateToCardPlaceholder).cardId) + } + + @Test + fun reloadClampsPageIndex() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithThreeLists()), + BoardsApiResult.Success(detailWithSingleList()), + ), + ), + ) + val viewModel = newLoadedViewModel(this, repository) + + viewModel.setCurrentPage(2) + viewModel.refreshBoardDetail() + advanceUntilIdle() + + assertEquals(0, viewModel.uiState.value.currentPageIndex) + } + + @Test + fun reloadPrunesSelectedIdsAgainstVisibleCards() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Success(detailOnlyCardThree()), + ), + ), + ) + val viewModel = newLoadedViewModel(this, repository) + + viewModel.onCardLongPressed("card-1") + viewModel.onCardLongPressed("card-3") + viewModel.refreshBoardDetail() + advanceUntilIdle() + + assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds) + } + + @Test + fun reloadClearsEditStateWhenEditedListDisappears() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Success(detailWithoutListOne()), + ), + ), + ) + val viewModel = newLoadedViewModel(this, repository) + + viewModel.startEditingList("list-1") + viewModel.refreshBoardDetail() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.editingListId) + assertEquals("", viewModel.uiState.value.editingListTitle) + } + + @Test + fun initialLoadFailureShowsFullScreenErrorAndRetryRecovers() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Failure("No network"), + BoardsApiResult.Success(detailWithSingleList()), + ), + ), + ) + val viewModel = BoardDetailViewModel(boardId = "board-1", repository = repository) + + viewModel.loadBoardDetail() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.boardDetail) + assertEquals("No network", viewModel.uiState.value.fullScreenErrorMessage) + + viewModel.retryLoad() + advanceUntilIdle() + + assertEquals("board-1", viewModel.uiState.value.boardDetail?.id) + assertNull(viewModel.uiState.value.fullScreenErrorMessage) + } + + @Test + fun refreshFailureKeepsStaleContentAndEmitsServerError() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithSingleList()), + BoardsApiResult.Failure("Refresh failed"), + ), + ), + ) + val viewModel = newLoadedViewModel(this, repository) + val eventDeferred = async { viewModel.events.first() } + + viewModel.refreshBoardDetail() + advanceUntilIdle() + + assertEquals("board-1", viewModel.uiState.value.boardDetail?.id) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowServerError) + assertEquals("Refresh failed", (event as BoardDetailUiEvent.ShowServerError).message) + } + + @Test + fun mutationSuccessWithReloadSuccessClearsSelection() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Success(detailWithTwoLists()), + ), + ), + moveCardsResult = CardBatchMutationResult.Success, + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.onCardLongPressed("card-1") + + viewModel.moveSelectedCards("list-2") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty()) + } + + @Test + fun mutationSuccessWithReloadFailureClearsSelectionAndWarns() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Failure("Reload failed"), + ), + ), + moveCardsResult = CardBatchMutationResult.Success, + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.onCardLongPressed("card-1") + val eventDeferred = async { viewModel.events.first() } + + viewModel.moveSelectedCards("list-2") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty()) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowWarning) + assertEquals( + "Changes applied, but refresh failed. Pull to refresh.", + (event as BoardDetailUiEvent.ShowWarning).message, + ) + } + + @Test + fun mutationPartialWithReloadSuccessReselectsFailedIdsStillVisibleAndWarns() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Success(detailOnlyCardThree()), + ), + ), + moveCardsResult = CardBatchMutationResult.PartialSuccess( + failedCardIds = setOf("card-2", "card-3"), + message = "Some cards failed.", + ), + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.onCardLongPressed("card-1") + viewModel.onCardLongPressed("card-2") + viewModel.onCardLongPressed("card-3") + val eventDeferred = async { viewModel.events.first() } + + viewModel.moveSelectedCards("list-2") + advanceUntilIdle() + + assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowWarning) + assertEquals("Some cards failed.", (event as BoardDetailUiEvent.ShowWarning).message) + } + + @Test + fun mutationPartialWithReloadFailurePreservesPreMutationSelectionAndWarns() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Failure("Reload failed"), + ), + ), + moveCardsResult = CardBatchMutationResult.PartialSuccess( + failedCardIds = setOf("card-2"), + message = "Some cards failed.", + ), + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.onCardLongPressed("card-1") + viewModel.onCardLongPressed("card-2") + val eventDeferred = async { viewModel.events.first() } + + viewModel.moveSelectedCards("list-2") + advanceUntilIdle() + + assertEquals(setOf("card-1", "card-2"), viewModel.uiState.value.selectedCardIds) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowWarning) + assertEquals( + "Some changes were applied, but refresh failed. Pull to refresh.", + (event as BoardDetailUiEvent.ShowWarning).message, + ) + } + + @Test + fun mutationFailurePreservesSelectionAndPageAndShowsServerError() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithThreeLists()))), + moveCardsResult = CardBatchMutationResult.Failure("Cannot move"), + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.setCurrentPage(2) + viewModel.onCardLongPressed("card-4") + val eventDeferred = async { viewModel.events.first() } + + viewModel.moveSelectedCards("list-2") + advanceUntilIdle() + + assertEquals(2, viewModel.uiState.value.currentPageIndex) + assertEquals(setOf("card-4"), viewModel.uiState.value.selectedCardIds) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowServerError) + assertEquals("Cannot move", (event as BoardDetailUiEvent.ShowServerError).message) + } + + @Test + fun renameNoOpSkipsApiCallAndExitsEditMode() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))), + ) + val viewModel = newLoadedViewModel(this, repository) + + viewModel.startEditingList("list-1") + viewModel.updateEditingTitle(" To Do ") + viewModel.submitRenameList() + advanceUntilIdle() + + assertEquals(0, repository.renameCalls) + assertNull(viewModel.uiState.value.editingListId) + } + + @Test + fun renameSuccessReloadsExitsEditAndPreservesPageAndSelectionSemantics() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Success(detailWithRenamedListOne()), + ), + ), + renameListResult = BoardsApiResult.Success(Unit), + ) + val viewModel = newLoadedViewModel(this, repository) + + viewModel.setCurrentPage(1) + viewModel.onCardLongPressed("card-3") + viewModel.startEditingList("list-1") + viewModel.updateEditingTitle("Renamed") + viewModel.submitRenameList() + advanceUntilIdle() + + assertEquals(1, repository.renameCalls) + assertEquals("list-1", repository.lastRenameListId) + assertEquals("Renamed", repository.lastRenameTitle) + assertNull(viewModel.uiState.value.editingListId) + assertEquals(1, viewModel.uiState.value.currentPageIndex) + assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds) + assertEquals("Renamed", viewModel.uiState.value.boardDetail?.lists?.first()?.title) + } + + @Test + fun renameFailureKeepsEditModeAndEmitsServerError() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))), + renameListResult = BoardsApiResult.Failure("Rename rejected"), + ) + val viewModel = newLoadedViewModel(this, repository) + val eventDeferred = async { viewModel.events.first() } + + viewModel.startEditingList("list-1") + viewModel.updateEditingTitle("Renamed") + viewModel.submitRenameList() + advanceUntilIdle() + + assertEquals("list-1", viewModel.uiState.value.editingListId) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowServerError) + assertEquals("Rename rejected", (event as BoardDetailUiEvent.ShowServerError).message) + } + + private fun newLoadedViewModel( + scope: TestScope, + repository: FakeBoardDetailDataSource, + initialDetail: BoardDetail = detailWithTwoLists(), + ): BoardDetailViewModel { + if (repository.boardDetailResults.isEmpty()) { + repository.boardDetailResults.add(BoardsApiResult.Success(initialDetail)) + } + + return BoardDetailViewModel(boardId = "board-1", repository = repository).also { + it.loadBoardDetail() + scope.advanceUntilIdle() + } + } + + private class FakeBoardDetailDataSource( + val boardDetailResults: ArrayDeque> = ArrayDeque(), + var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, + var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, + var renameListResult: BoardsApiResult = BoardsApiResult.Success(Unit), + ) : BoardDetailDataSource { + var renameCalls: Int = 0 + var lastRenameListId: String? = null + var lastRenameTitle: String? = null + + override suspend fun getBoardDetail(boardId: String): BoardsApiResult { + return if (boardDetailResults.isNotEmpty()) { + boardDetailResults.removeFirst() + } else { + BoardsApiResult.Success(detailWithSingleList()) + } + } + + override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + return moveCardsResult + } + + override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + return deleteCardsResult + } + + override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { + renameCalls += 1 + lastRenameListId = listId + lastRenameTitle = newTitle + return renameListResult + } + } + + private companion object { + fun detailWithSingleList(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)), + ), + ), + ) + } + + fun detailWithTwoLists(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary("card-1", "Card 1", emptyList(), null), + BoardCardSummary("card-2", "Card 2", emptyList(), null), + ), + ), + BoardListDetail( + id = "list-2", + title = "Doing", + cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)), + ), + ), + ) + } + + fun detailWithThreeLists(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)), + ), + BoardListDetail( + id = "list-2", + title = "Doing", + cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)), + ), + BoardListDetail( + id = "list-3", + title = "Done", + cards = listOf(BoardCardSummary("card-4", "Card 4", emptyList(), null)), + ), + ), + ) + } + + fun detailOnlyCardThree(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-2", + title = "Doing", + cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)), + ), + ), + ) + } + + fun detailWithoutListOne(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-2", + title = "Doing", + cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)), + ), + ), + ) + } + + fun detailWithRenamedListOne(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "Renamed", + cards = listOf( + BoardCardSummary("card-1", "Card 1", emptyList(), null), + BoardCardSummary("card-2", "Card 2", emptyList(), null), + ), + ), + BoardListDetail( + id = "list-2", + title = "Doing", + cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)), + ), + ), + ) + } + } +} From 4455f0ecd3eb5434e0abbba7d0b511494d2dbd87 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:47:18 -0400 Subject: [PATCH 10/16] test: cover delete flows and guard re-entrant mutations --- .../app/boarddetail/BoardDetailViewModel.kt | 6 + .../boarddetail/BoardDetailViewModelTest.kt | 128 ++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt index 3540ec7..fa4368a 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt @@ -153,6 +153,9 @@ class BoardDetailViewModel( fun submitRenameList() { val snapshot = _uiState.value + if (snapshot.isMutating) { + return + } val editingListId = snapshot.editingListId ?: return val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return val trimmedTitle = snapshot.editingListTitle.trim() @@ -238,6 +241,9 @@ class BoardDetailViewModel( mutation: suspend (Set) -> CardBatchMutationResult, ) { val preMutation = _uiState.value + if (preMutation.isMutating) { + return + } val selectedIds = preMutation.selectedCardIds if (selectedIds.isEmpty()) { return diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt index e52465c..6783da5 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt @@ -1,6 +1,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher @@ -304,6 +305,99 @@ class BoardDetailViewModelTest { assertEquals("Cannot move", (event as BoardDetailUiEvent.ShowServerError).message) } + @Test + fun deleteSuccessWithReloadSuccessClearsSelection() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Success(detailWithTwoLists()), + ), + ), + deleteCardsResult = CardBatchMutationResult.Success, + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.onCardLongPressed("card-1") + + viewModel.deleteSelectedCards() + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty()) + } + + @Test + fun deletePartialWithReloadSuccessReselectsFailedIdsStillVisibleAndWarns() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Success(detailOnlyCardThree()), + ), + ), + deleteCardsResult = CardBatchMutationResult.PartialSuccess( + failedCardIds = setOf("card-2", "card-3"), + message = "Some cards could not be deleted.", + ), + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.onCardLongPressed("card-1") + viewModel.onCardLongPressed("card-2") + viewModel.onCardLongPressed("card-3") + val eventDeferred = async { viewModel.events.first() } + + viewModel.deleteSelectedCards() + advanceUntilIdle() + + assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowWarning) + assertEquals("Some cards could not be deleted.", (event as BoardDetailUiEvent.ShowWarning).message) + } + + @Test + fun deleteFailurePreservesSelectionAndEmitsServerError() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithTwoLists()))), + deleteCardsResult = CardBatchMutationResult.Failure("Delete blocked"), + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.onCardLongPressed("card-1") + val eventDeferred = async { viewModel.events.first() } + + viewModel.deleteSelectedCards() + advanceUntilIdle() + + assertEquals(setOf("card-1"), viewModel.uiState.value.selectedCardIds) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowServerError) + assertEquals("Delete blocked", (event as BoardDetailUiEvent.ShowServerError).message) + } + + @Test + fun runMutationNoOpsWhenAlreadyMutating() = runTest { + val gate = CompletableDeferred() + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithTwoLists()), + BoardsApiResult.Success(detailWithTwoLists()), + ), + ), + moveCardsResult = CardBatchMutationResult.Success, + moveGate = gate, + ) + val viewModel = newLoadedViewModel(this, repository) + viewModel.onCardLongPressed("card-1") + + viewModel.moveSelectedCards("list-2") + advanceUntilIdle() + viewModel.moveSelectedCards("list-2") + gate.complete(Unit) + advanceUntilIdle() + + assertEquals(1, repository.moveCalls) + } + @Test fun renameNoOpSkipsApiCallAndExitsEditMode() = runTest { val repository = FakeBoardDetailDataSource( @@ -369,6 +463,32 @@ class BoardDetailViewModelTest { assertEquals("Rename rejected", (event as BoardDetailUiEvent.ShowServerError).message) } + @Test + fun submitRenameNoOpsWhenAlreadyMutating() = runTest { + val gate = CompletableDeferred() + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithSingleList()), + BoardsApiResult.Success(detailWithSingleList()), + ), + ), + renameListResult = BoardsApiResult.Success(Unit), + renameGate = gate, + ) + val viewModel = newLoadedViewModel(this, repository) + + viewModel.startEditingList("list-1") + viewModel.updateEditingTitle("Renamed") + viewModel.submitRenameList() + advanceUntilIdle() + viewModel.submitRenameList() + gate.complete(Unit) + advanceUntilIdle() + + assertEquals(1, repository.renameCalls) + } + private fun newLoadedViewModel( scope: TestScope, repository: FakeBoardDetailDataSource, @@ -389,7 +509,11 @@ class BoardDetailViewModelTest { var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, var renameListResult: BoardsApiResult = BoardsApiResult.Success(Unit), + var moveGate: CompletableDeferred? = null, + var renameGate: CompletableDeferred? = null, ) : BoardDetailDataSource { + var moveCalls: Int = 0 + var deleteCalls: Int = 0 var renameCalls: Int = 0 var lastRenameListId: String? = null var lastRenameTitle: String? = null @@ -403,15 +527,19 @@ class BoardDetailViewModelTest { } override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + moveCalls += 1 + moveGate?.await() return moveCardsResult } override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + deleteCalls += 1 return deleteCardsResult } override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { renameCalls += 1 + renameGate?.await() lastRenameListId = listId lastRenameTitle = newTitle return renameListResult From 5f5a273d7f1eeed0d7f0f6bb27e5adeaaf842db8 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 01:19:15 -0400 Subject: [PATCH 11/16] feat: implement board detail pager UI and card rendering --- .../kanbn4droid/app/BoardDetailFlowTest.kt | 357 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 3 + .../app/boarddetail/BoardDetailActivity.kt | 317 ++++++++++++++++ .../app/boarddetail/BoardListsPagerAdapter.kt | 161 ++++++++ .../app/boarddetail/CardsAdapter.kt | 116 ++++++ .../main/res/layout/activity_board_detail.xml | 71 ++++ .../res/layout/item_board_card_detail.xml | 39 ++ .../main/res/layout/item_board_list_page.xml | 57 +++ .../res/menu/menu_board_detail_selection.xml | 22 ++ app/src/main/res/values/strings.xml | 12 + 10 files changed, 1155 insertions(+) create mode 100644 app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardListsPagerAdapter.kt create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/CardsAdapter.kt create mode 100644 app/src/main/res/layout/activity_board_detail.xml create mode 100644 app/src/main/res/layout/item_board_card_detail.xml create mode 100644 app/src/main/res/layout/item_board_list_page.xml create mode 100644 app/src/main/res/menu/menu_board_detail_selection.xml diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt new file mode 100644 index 0000000..ef032bb --- /dev/null +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -0,0 +1,357 @@ +package space.hackenslacker.kanbn4droid.app + +import android.content.Intent +import android.graphics.Color +import android.view.inputmethod.EditorInfo +import android.view.View +import android.widget.TextView +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.longClick +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.color.MaterialColors +import java.text.DateFormat +import java.util.ArrayDeque +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.CompletableDeferred +import org.hamcrest.Matchers.not +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailDataSource +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary +import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +@RunWith(AndroidJUnit4::class) +class BoardDetailFlowTest { + + private lateinit var defaultDataSource: FakeBoardDetailDataSource + private var originalLocale: Locale? = null + + @Before + fun setUp() { + originalLocale = Locale.getDefault() + defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList()) + BoardDetailActivity.testDataSourceFactory = { defaultDataSource } + } + + @After + fun tearDown() { + BoardDetailActivity.testDataSourceFactory = null + originalLocale?.let { Locale.setDefault(it) } + } + + @Test + fun boardDetailShowsListTitleAndCards() { + launchBoardDetail() + + onView(withText("To Do")).check(matches(isDisplayed())) + onView(withText("Card 1")).check(matches(isDisplayed())) + } + + @Test + fun initialLoadShowsProgressThenContent() { + val gate = CompletableDeferred() + defaultDataSource.loadGate = gate + + val scenario = launchBoardDetail() + onView(withId(R.id.boardDetailInitialProgress)).check(matches(isDisplayed())) + + gate.complete(Unit) + scenario.onActivity { } + + onView(withText("Card 1")).check(matches(isDisplayed())) + } + + @Test + fun emptyBoardShowsNoListsYetMessage() { + defaultDataSource.currentDetail = BoardDetail(id = "board-1", title = "Board", lists = emptyList()) + + launchBoardDetail() + + onView(withText(R.string.board_detail_empty_board)).check(matches(isDisplayed())) + } + + @Test + fun initialLoadFailureShowsRetryAndRetryReloads() { + defaultDataSource.loadResults.add(BoardsApiResult.Failure("Load failed")) + defaultDataSource.loadResults.add(BoardsApiResult.Success(detailOneList())) + + launchBoardDetail() + + onView(withText("Load failed")).check(matches(isDisplayed())) + onView(withId(R.id.boardDetailRetryButton)).perform(click()) + onView(withText("Card 1")).check(matches(isDisplayed())) + } + + @Test + fun expiredDueDateUsesErrorColor() { + defaultDataSource.currentDetail = detailWithDueDate(System.currentTimeMillis() - 3_600_000) + val scenario = launchBoardDetail() + + var expectedColor: Int? = null + scenario.onActivity { activity -> + expectedColor = MaterialColors.getColor(activity.findViewById(android.R.id.content), com.google.android.material.R.attr.colorError) + } + onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.RED))) + } + + @Test + fun validDueDateUsesOnSurfaceColor() { + defaultDataSource.currentDetail = detailWithDueDate(System.currentTimeMillis() + 86_400_000) + val scenario = launchBoardDetail() + + var expectedColor: Int? = null + scenario.onActivity { activity -> + expectedColor = MaterialColors.getColor(activity.findViewById(android.R.id.content), com.google.android.material.R.attr.colorOnSurface) + } + onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.BLACK))) + } + + @Test + fun dueDateUsesSystemLocaleFormatting() { + Locale.setDefault(Locale.FRANCE) + val due = 1_735_776_000_000L + defaultDataSource.currentDetail = detailWithDueDate(due) + + launchBoardDetail() + + val expected = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()).format(Date(due)) + onView(withText(expected)).check(matches(isDisplayed())) + } + + @Test + fun inlineListTitleEdit_savesOnImeDone() { + defaultDataSource.currentDetail = detailOneList() + val scenario = launchBoardDetail() + + onView(withId(R.id.listTitleText)).perform(click()) + scenario.onActivity { activity -> + val input = activity.findViewById(R.id.listTitleEditInput) + input.setText("Renamed") + input.onEditorAction(EditorInfo.IME_ACTION_DONE) + } + + assertEquals(1, defaultDataSource.renameCalls) + assertEquals("Renamed", defaultDataSource.lastRenameTitle) + } + + @Test + fun inlineListTitleEdit_savesOnFocusLoss() { + launchBoardDetail() + + onView(withId(R.id.listTitleText)).perform(click()) + onView(withId(R.id.listTitleEditInput)).perform(replaceText("Renamed again")) + onView(withId(R.id.listCardsRecycler)).perform(click()) + + assertEquals(1, defaultDataSource.renameCalls) + assertEquals("Renamed again", defaultDataSource.lastRenameTitle) + } + + @Test + fun inlineListTitleEdit_trimmedNoOpSkipsRenameCall() { + val scenario = launchBoardDetail() + + onView(withId(R.id.listTitleText)).perform(click()) + scenario.onActivity { activity -> + val input = activity.findViewById(R.id.listTitleEditInput) + input.setText(" To Do ") + input.onEditorAction(EditorInfo.IME_ACTION_DONE) + } + + assertEquals(0, defaultDataSource.renameCalls) + } + + @Test + fun inlineListTitleEdit_rejectsBlankTitle() { + val scenario = launchBoardDetail() + + onView(withId(R.id.listTitleText)).perform(click()) + scenario.onActivity { activity -> + val input = activity.findViewById(R.id.listTitleEditInput) + input.setText(" ") + input.onEditorAction(EditorInfo.IME_ACTION_DONE) + } + + onView(withText(R.string.list_title_required)).check(matches(isDisplayed())) + assertEquals(0, defaultDataSource.renameCalls) + } + + @Test + fun inlineListTitleEdit_renameFailure_keepsEditModeAndShowsError() { + defaultDataSource.renameResult = BoardsApiResult.Failure("Rename rejected") + val scenario = launchBoardDetail() + + onView(withId(R.id.listTitleText)).perform(click()) + scenario.onActivity { activity -> + val input = activity.findViewById(R.id.listTitleEditInput) + input.setText("X") + input.onEditorAction(EditorInfo.IME_ACTION_DONE) + } + + onView(withId(R.id.listTitleEditInput)).check(matches(isDisplayed())) + onView(withText("Rename rejected")).check(matches(isDisplayed())) + } + + @Test + fun inlineListTitleEdit_saveDisabledWhileMutating() { + defaultDataSource.renameGate = CompletableDeferred() + val scenario = launchBoardDetail() + + onView(withId(R.id.listTitleText)).perform(click()) + scenario.onActivity { activity -> + val input = activity.findViewById(R.id.listTitleEditInput) + input.setText("New") + input.onEditorAction(EditorInfo.IME_ACTION_DONE) + } + + onView(withId(R.id.listTitleEditInput)).check(matches(not(isEnabled()))) + defaultDataSource.renameGate?.complete(Unit) + } + + @Test + fun selectionModeActionsShowTooltipsOnLongPress() { + val scenario = launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + + scenario.onActivity { activity -> + val menu = activity.findViewById(R.id.boardDetailToolbar).menu + assertEquals(activity.getString(R.string.select_all), menu.findItem(R.id.actionSelectAll)?.tooltipText?.toString()) + assertEquals(activity.getString(R.string.move_cards), menu.findItem(R.id.actionMoveCards)?.tooltipText?.toString()) + assertEquals(activity.getString(R.string.delete_cards), menu.findItem(R.id.actionDeleteCards)?.tooltipText?.toString()) + } + } + + private fun launchBoardDetail(): ActivityScenario { + val intent = Intent( + androidx.test.core.app.ApplicationProvider.getApplicationContext(), + BoardDetailActivity::class.java, + ).putExtra(BoardDetailActivity.EXTRA_BOARD_ID, "board-1") + .putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, "Board") + return ActivityScenario.launch(intent) + } + + private fun withCurrentTextColor(expectedColor: Int): Matcher { + return object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("with text color: $expectedColor") + } + + override fun matchesSafely(item: View): Boolean { + if (item !is TextView) { + return false + } + return item.currentTextColor == expectedColor + } + } + } + + private class FakeBoardDetailDataSource( + initialDetail: BoardDetail, + ) : BoardDetailDataSource { + var currentDetail: BoardDetail = initialDetail + val loadResults: ArrayDeque> = ArrayDeque() + var loadGate: CompletableDeferred? = null + var renameResult: BoardsApiResult = BoardsApiResult.Success(Unit) + var renameGate: CompletableDeferred? = null + var renameCalls: Int = 0 + var lastRenameTitle: String? = null + + override suspend fun getBoardDetail(boardId: String): BoardsApiResult { + loadGate?.await() + return if (loadResults.isNotEmpty()) { + loadResults.removeFirst() + } else { + BoardsApiResult.Success(currentDetail) + } + } + + override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + return CardBatchMutationResult.Success + } + + override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + return CardBatchMutationResult.Success + } + + override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { + renameCalls += 1 + lastRenameTitle = newTitle + renameGate?.await() + val result = renameResult + if (result is BoardsApiResult.Success) { + currentDetail = currentDetail.copy( + lists = currentDetail.lists.map { list -> + if (list.id == listId) list.copy(title = newTitle) else list + }, + ) + } + return result + } + } + + private companion object { + fun detailOneList(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary( + id = "card-1", + title = "Card 1", + tags = listOf(BoardTagSummary("tag-1", "Backend", "#008080")), + dueAtEpochMillis = null, + ), + ), + ), + ), + ) + } + + fun detailWithDueDate(dueAtEpochMillis: Long): BoardDetail { + return detailOneList().copy( + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary( + id = "card-1", + title = "Card 1", + tags = emptyList(), + dueAtEpochMillis = dueAtEpochMillis, + ), + ), + ), + ), + ) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 110764f..14acd5f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt new file mode 100644 index 0000000..452e6a9 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt @@ -0,0 +1,317 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.Button +import android.widget.ProgressBar +import android.widget.TextView +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import space.hackenslacker.kanbn4droid.app.MainActivity +import space.hackenslacker.kanbn4droid.app.R +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences +import space.hackenslacker.kanbn4droid.app.auth.SessionStore + +class BoardDetailActivity : AppCompatActivity() { + + private lateinit var boardId: String + private lateinit var sessionStore: SessionStore + private lateinit var apiKeyStore: ApiKeyStore + private lateinit var apiClient: KanbnApiClient + + private lateinit var toolbar: MaterialToolbar + private lateinit var pager: ViewPager2 + private lateinit var emptyBoardText: TextView + private lateinit var initialProgress: ProgressBar + private lateinit var fullScreenErrorContainer: View + private lateinit var fullScreenErrorText: TextView + private lateinit var retryButton: Button + + private var inlineTitleErrorMessage: String? = null + + private lateinit var pagerAdapter: BoardListsPagerAdapter + + private val viewModel: BoardDetailViewModel by viewModels { + val id = boardId + val fakeFactory = testDataSourceFactory + if (fakeFactory != null) { + object : androidx.lifecycle.ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) { + return BoardDetailViewModel(id, fakeFactory.invoke(id)) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } + } + } else { + BoardDetailViewModel.Factory( + boardId = id, + repository = BoardDetailRepository( + sessionStore = sessionStore, + apiKeyStore = apiKeyStore, + apiClient = apiClient, + ), + ) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + boardId = intent.getStringExtra(EXTRA_BOARD_ID).orEmpty() + sessionStore = provideSessionStore() + apiKeyStore = provideApiKeyStore() + apiClient = provideApiClient() + + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_board_detail) + + bindViews() + setupToolbar() + setupPager() + observeViewModel() + + viewModel.loadBoardDetail() + } + + override fun onBackPressed() { + if (!viewModel.onBackPressed()) { + super.onBackPressed() + } + } + + private fun bindViews() { + toolbar = findViewById(R.id.boardDetailToolbar) + pager = findViewById(R.id.boardDetailPager) + emptyBoardText = findViewById(R.id.boardDetailEmptyBoardText) + initialProgress = findViewById(R.id.boardDetailInitialProgress) + fullScreenErrorContainer = findViewById(R.id.boardDetailFullScreenErrorContainer) + fullScreenErrorText = findViewById(R.id.boardDetailFullScreenErrorText) + retryButton = findViewById(R.id.boardDetailRetryButton) + } + + private fun setupToolbar() { + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty() + toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + retryButton.setOnClickListener { + viewModel.retryLoad() + } + } + + private fun setupPager() { + pagerAdapter = BoardListsPagerAdapter( + onListTitleClicked = { listId -> + inlineTitleErrorMessage = null + viewModel.startEditingList(listId) + }, + onEditingTitleChanged = { title -> + inlineTitleErrorMessage = null + viewModel.updateEditingTitle(title) + }, + onSubmitEditingTitle = { submitted -> + val trimmed = submitted.trim() + if (trimmed.isBlank()) { + inlineTitleErrorMessage = getString(R.string.list_title_required) + viewModel.updateEditingTitle(submitted) + render(viewModel.uiState.value) + } else { + inlineTitleErrorMessage = null + viewModel.updateEditingTitle(submitted) + viewModel.submitRenameList() + } + }, + onCardClick = { card -> viewModel.onCardTapped(card.id) }, + onCardLongClick = { card -> viewModel.onCardLongPressed(card.id) }, + ) + pager.adapter = pagerAdapter + pager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + viewModel.setCurrentPage(position) + } + }, + ) + } + + private fun observeViewModel() { + lifecycleScope.launch { + viewModel.uiState.collect { render(it) } + } + lifecycleScope.launch { + viewModel.events.collect { event -> + when (event) { + is BoardDetailUiEvent.NavigateToCardPlaceholder -> { + Snackbar.make(pager, getString(R.string.board_detail_card_detail_coming_soon), Snackbar.LENGTH_SHORT).show() + } + + is BoardDetailUiEvent.ShowServerError -> { + if (viewModel.uiState.value.editingListId != null) { + inlineTitleErrorMessage = event.message + render(viewModel.uiState.value) + } else { + MaterialAlertDialogBuilder(this@BoardDetailActivity) + .setMessage(event.message) + .setPositiveButton(R.string.ok, null) + .show() + } + } + + is BoardDetailUiEvent.ShowWarning -> { + Snackbar.make(pager, event.message, Snackbar.LENGTH_LONG).show() + } + } + } + } + } + + private fun render(state: BoardDetailUiState) { + supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty() + + fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) { + fullScreenErrorText.text = state.fullScreenErrorMessage + View.VISIBLE + } else { + View.GONE + } + initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE + + val boardLists = state.boardDetail?.lists.orEmpty() + val applyPagerState = { + pagerAdapter.submit( + lists = boardLists, + selectedCardIds = state.selectedCardIds, + editingListId = state.editingListId, + editingListTitle = state.editingListTitle, + isMutating = state.isMutating, + inlineEditErrorMessage = inlineTitleErrorMessage, + ) + pager.visibility = if (boardLists.isNotEmpty()) View.VISIBLE else View.GONE + emptyBoardText.visibility = if (!state.isInitialLoading && state.fullScreenErrorMessage == null && boardLists.isEmpty()) { + View.VISIBLE + } else { + View.GONE + } + if (boardLists.isNotEmpty() && pager.currentItem != state.currentPageIndex) { + pager.setCurrentItem(state.currentPageIndex, false) + } + } + val pagerRecycler = pager.getChildAt(0) as? RecyclerView + if (pagerRecycler?.isComputingLayout == true) { + pager.post { + if (!isFinishing && !isDestroyed) { + applyPagerState() + } + } + } else { + applyPagerState() + } + + renderSelectionActions(state) + } + + private fun renderSelectionActions(state: BoardDetailUiState) { + val inSelection = state.selectedCardIds.isNotEmpty() + toolbar.menu.clear() + if (!inSelection) { + return + } + toolbar.inflateMenu(R.menu.menu_board_detail_selection) + toolbar.menu.findItem(R.id.actionSelectAll)?.tooltipText = getString(R.string.select_all) + toolbar.menu.findItem(R.id.actionMoveCards)?.tooltipText = getString(R.string.move_cards) + toolbar.menu.findItem(R.id.actionDeleteCards)?.tooltipText = getString(R.string.delete_cards) + toolbar.setOnMenuItemClickListener { item -> + handleSelectionAction(item) + } + } + + private fun handleSelectionAction(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.actionSelectAll -> { + viewModel.selectAllOnCurrentPage() + true + } + + R.id.actionMoveCards -> { + showMoveCardsDialog() + true + } + + R.id.actionDeleteCards -> { + showDeleteCardsDialog() + true + } + + else -> false + } + } + + private fun showMoveCardsDialog() { + val lists = viewModel.uiState.value.boardDetail?.lists.orEmpty() + if (lists.isEmpty()) { + return + } + val listNames = lists.map { it.title }.toTypedArray() + var selectedIndex = viewModel.uiState.value.currentPageIndex.coerceIn(0, lists.lastIndex) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.move_cards_to_list) + .setSingleChoiceItems(listNames, selectedIndex) { _, which -> selectedIndex = which } + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.move_cards) { _, _ -> + val targetId = lists[selectedIndex].id + viewModel.moveSelectedCards(targetId) + } + .show() + } + + private fun showDeleteCardsDialog() { + MaterialAlertDialogBuilder(this) + .setMessage(R.string.delete_cards_confirmation) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete) { _, _ -> + MaterialAlertDialogBuilder(this) + .setMessage(R.string.delete_cards_second_confirmation) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.im_sure) { _, _ -> + viewModel.deleteSelectedCards() + } + .show() + } + .show() + } + + protected fun provideSessionStore(): SessionStore { + return MainActivity.dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext) + } + + protected fun provideApiKeyStore(): ApiKeyStore { + return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this) + ?: PreferencesApiKeyStore(this) + } + + protected fun provideApiClient(): KanbnApiClient { + return MainActivity.dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient() + } + + companion object { + const val EXTRA_BOARD_ID = "extra_board_id" + const val EXTRA_BOARD_TITLE = "extra_board_title" + + var testDataSourceFactory: ((String) -> BoardDetailDataSource)? = null + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardListsPagerAdapter.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardListsPagerAdapter.kt new file mode 100644 index 0000000..be4013b --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardListsPagerAdapter.kt @@ -0,0 +1,161 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import android.text.Editable +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.textfield.TextInputLayout +import space.hackenslacker.kanbn4droid.app.R + +class BoardListsPagerAdapter( + private val onListTitleClicked: (String) -> Unit, + private val onEditingTitleChanged: (String) -> Unit, + private val onSubmitEditingTitle: (String) -> Unit, + private val onCardClick: (BoardCardSummary) -> Unit, + private val onCardLongClick: (BoardCardSummary) -> Unit, +) : RecyclerView.Adapter() { + + private var lists: List = emptyList() + private var selectedCardIds: Set = emptySet() + private var editingListId: String? = null + private var editingListTitle: String = "" + private var isMutating: Boolean = false + private var inlineEditErrorMessage: String? = null + + fun submit( + lists: List, + selectedCardIds: Set, + editingListId: String?, + editingListTitle: String, + isMutating: Boolean, + inlineEditErrorMessage: String?, + ) { + this.lists = lists + this.selectedCardIds = selectedCardIds + this.editingListId = editingListId + this.editingListTitle = editingListTitle + this.isMutating = isMutating + this.inlineEditErrorMessage = inlineEditErrorMessage + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListPageViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_board_list_page, parent, false) + return ListPageViewHolder(view, onListTitleClicked, onEditingTitleChanged, onSubmitEditingTitle, onCardClick, onCardLongClick) + } + + override fun getItemCount(): Int = lists.size + + override fun onBindViewHolder(holder: ListPageViewHolder, position: Int) { + val list = lists[position] + holder.bind( + list = list, + selectedCardIds = selectedCardIds, + isEditing = list.id == editingListId, + editingTitle = editingListTitle, + isMutating = isMutating, + inlineEditErrorMessage = inlineEditErrorMessage, + ) + } + + class ListPageViewHolder( + itemView: View, + private val onListTitleClicked: (String) -> Unit, + private val onEditingTitleChanged: (String) -> Unit, + private val onSubmitEditingTitle: (String) -> Unit, + onCardClick: (BoardCardSummary) -> Unit, + onCardLongClick: (BoardCardSummary) -> Unit, + ) : RecyclerView.ViewHolder(itemView) { + + private val listTitleText: TextView = itemView.findViewById(R.id.listTitleText) + private val listTitleInputLayout: TextInputLayout = itemView.findViewById(R.id.listTitleInputLayout) + private val listTitleEditInput: EditText = itemView.findViewById(R.id.listTitleEditInput) + private val cardsRecycler: RecyclerView = itemView.findViewById(R.id.listCardsRecycler) + private val emptyText: TextView = itemView.findViewById(R.id.listEmptyText) + private val cardsAdapter = CardsAdapter(onCardClick = onCardClick, onCardLongClick = onCardLongClick) + + private var isBinding = false + private var attachedListId: String? = null + + init { + cardsRecycler.adapter = cardsAdapter + + listTitleEditInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(editable: Editable?) { + if (isBinding) { + return + } + onEditingTitleChanged(editable?.toString().orEmpty()) + } + }) + + listTitleEditInput.setOnEditorActionListener { _, actionId, event -> + val imeDone = actionId == EditorInfo.IME_ACTION_DONE + val enterKey = event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN + if (imeDone || enterKey) { + onSubmitEditingTitle(listTitleEditInput.text?.toString().orEmpty()) + true + } else { + false + } + } + + listTitleEditInput.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + if (isBinding || hasFocus) { + return@OnFocusChangeListener + } + if (listTitleInputLayout.visibility == View.VISIBLE) { + onSubmitEditingTitle(listTitleEditInput.text?.toString().orEmpty()) + } + } + } + + fun bind( + list: BoardListDetail, + selectedCardIds: Set, + isEditing: Boolean, + editingTitle: String, + isMutating: Boolean, + inlineEditErrorMessage: String?, + ) { + attachedListId = list.id + listTitleText.text = list.title + listTitleText.setOnClickListener { onListTitleClicked(list.id) } + + cardsAdapter.submitCards(list.cards, selectedCardIds) + val hasCards = list.cards.isNotEmpty() + cardsRecycler.visibility = if (hasCards) View.VISIBLE else View.GONE + emptyText.visibility = if (hasCards) View.GONE else View.VISIBLE + + isBinding = true + if (isEditing) { + listTitleText.visibility = View.GONE + listTitleInputLayout.visibility = View.VISIBLE + listTitleEditInput.isEnabled = !isMutating + if (listTitleEditInput.text?.toString() != editingTitle) { + listTitleEditInput.setText(editingTitle) + listTitleEditInput.setSelection(editingTitle.length) + } + listTitleInputLayout.error = inlineEditErrorMessage + if (!listTitleEditInput.hasFocus()) { + listTitleEditInput.requestFocus() + } + } else { + listTitleInputLayout.visibility = View.GONE + listTitleText.visibility = View.VISIBLE + listTitleInputLayout.error = null + } + isBinding = false + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/CardsAdapter.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/CardsAdapter.kt new file mode 100644 index 0000000..18823c7 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/CardsAdapter.kt @@ -0,0 +1,116 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView +import com.google.android.material.chip.Chip +import com.google.android.material.color.MaterialColors +import space.hackenslacker.kanbn4droid.app.R +import java.text.DateFormat as JavaDateFormat +import java.util.Date + +class CardsAdapter( + private val onCardClick: (BoardCardSummary) -> Unit, + private val onCardLongClick: (BoardCardSummary) -> Unit, +) : RecyclerView.Adapter() { + + private var cards: List = emptyList() + private var selectedCardIds: Set = emptySet() + + fun submitCards(cards: List, selectedCardIds: Set) { + this.cards = cards + this.selectedCardIds = selectedCardIds + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_board_card_detail, parent, false) + return CardViewHolder(view) + } + + override fun onBindViewHolder(holder: CardViewHolder, position: Int) { + holder.bind(cards[position], selectedCardIds.contains(cards[position].id), onCardClick, onCardLongClick) + } + + override fun getItemCount(): Int = cards.size + + class CardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val rootCard: MaterialCardView = itemView.findViewById(R.id.cardItemRoot) + private val titleText: TextView = itemView.findViewById(R.id.cardTitleText) + private val tagsContainer: LinearLayout = itemView.findViewById(R.id.cardTagsContainer) + private val dueDateText: TextView = itemView.findViewById(R.id.cardDueDateText) + + fun bind( + card: BoardCardSummary, + isSelected: Boolean, + onCardClick: (BoardCardSummary) -> Unit, + onCardLongClick: (BoardCardSummary) -> Unit, + ) { + titleText.text = card.title + bindTags(card.tags) + bindDueDate(card.dueAtEpochMillis) + + rootCard.isChecked = isSelected + rootCard.strokeWidth = if (isSelected) 4 else 1 + val strokeColor = if (isSelected) { + MaterialColors.getColor(rootCard, com.google.android.material.R.attr.colorPrimary, Color.BLUE) + } else { + MaterialColors.getColor(rootCard, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY) + } + rootCard.strokeColor = strokeColor + + itemView.setOnClickListener { onCardClick(card) } + itemView.setOnLongClickListener { + onCardLongClick(card) + true + } + } + + private fun bindTags(tags: List) { + tagsContainer.removeAllViews() + tagsContainer.visibility = if (tags.isEmpty()) View.GONE else View.VISIBLE + tags.forEach { tag -> + val chip = Chip(itemView.context) + chip.text = tag.name + chip.isClickable = false + chip.isCheckable = false + chip.chipBackgroundColor = null + chip.chipStrokeWidth = 2f + chip.chipStrokeColor = android.content.res.ColorStateList.valueOf(parseColorOrFallback(tag.colorHex)) + tagsContainer.addView(chip) + } + } + + private fun bindDueDate(dueAtEpochMillis: Long?) { + if (dueAtEpochMillis == null) { + dueDateText.visibility = View.GONE + dueDateText.text = "" + return + } + + val isExpired = dueAtEpochMillis < System.currentTimeMillis() + val color = if (isExpired) { + MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorError, Color.RED) + } else { + MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorOnSurface, Color.BLACK) + } + dueDateText.setTextColor(color) + val formatted = JavaDateFormat.getDateInstance(JavaDateFormat.MEDIUM, java.util.Locale.getDefault()) + .format(Date(dueAtEpochMillis)) + dueDateText.text = formatted + dueDateText.visibility = View.VISIBLE + } + + private fun parseColorOrFallback(colorHex: String): Int { + return runCatching { Color.parseColor(colorHex) } + .getOrElse { + MaterialColors.getColor(itemView, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY) + } + } + } +} diff --git a/app/src/main/res/layout/activity_board_detail.xml b/app/src/main/res/layout/activity_board_detail.xml new file mode 100644 index 0000000..66ed963 --- /dev/null +++ b/app/src/main/res/layout/activity_board_detail.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_board_card_detail.xml b/app/src/main/res/layout/item_board_card_detail.xml new file mode 100644 index 0000000..83eea6c --- /dev/null +++ b/app/src/main/res/layout/item_board_card_detail.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_board_list_page.xml b/app/src/main/res/layout/item_board_list_page.xml new file mode 100644 index 0000000..a51b697 --- /dev/null +++ b/app/src/main/res/layout/item_board_list_page.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_board_detail_selection.xml b/app/src/main/res/menu/menu_board_detail_selection.xml new file mode 100644 index 0000000..09f4048 --- /dev/null +++ b/app/src/main/res/menu/menu_board_detail_selection.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9c8472..1c0679b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,4 +32,16 @@ Cannot reach server. Check your connection and URL. Authentication failed. Check your API key. Unexpected error. Please try again. + No lists yet. + No cards in this list. + Retry + List title + List title is required + Select all + Move cards + Delete cards + Move cards to list + Delete selected cards? + Are you sure you want to permanently delete the selected cards? + Card detail view is coming soon. From f5ac01de093b1143a5fd44d2c2d5178c667f31e8 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 01:33:48 -0400 Subject: [PATCH 12/16] feat: route board and card taps to detail screens --- .../kanbn4droid/app/BoardDetailFlowTest.kt | 47 +++++++++++++++++++ .../kanbn4droid/app/BoardsFlowTest.kt | 12 ++--- app/src/main/AndroidManifest.xml | 2 +- .../app/BoardDetailPlaceholderActivity.kt | 24 ---------- .../kanbn4droid/app/BoardsActivity.kt | 7 +-- .../app/CardDetailPlaceholderActivity.kt | 24 ++++++++++ .../app/boarddetail/BoardDetailActivity.kt | 18 ++++++- .../activity_card_detail_placeholder.xml | 30 ++++++++++++ 8 files changed, 129 insertions(+), 35 deletions(-) delete mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardDetailPlaceholderActivity.kt create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/CardDetailPlaceholderActivity.kt create mode 100644 app/src/main/res/layout/activity_card_detail_placeholder.xml diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt index ef032bb..f73ad9b 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -11,6 +11,9 @@ import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.isDisplayed @@ -53,12 +56,14 @@ class BoardDetailFlowTest { @Before fun setUp() { originalLocale = Locale.getDefault() + Intents.init() defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList()) BoardDetailActivity.testDataSourceFactory = { defaultDataSource } } @After fun tearDown() { + Intents.release() BoardDetailActivity.testDataSourceFactory = null originalLocale?.let { Locale.setDefault(it) } } @@ -245,6 +250,29 @@ class BoardDetailFlowTest { } } + @Test + fun cardTapNavigatesToCardPlaceholderWithExtras() { + launchBoardDetail() + + onView(withText("Card 1")).perform(click()) + + Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name)) + Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1")) + Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, "Card 1")) + } + + @Test + fun cardTapBlankTitle_usesCardFallbackInPlaceholderExtra() { + defaultDataSource.currentDetail = detailWithCardTitle(" ") + launchBoardDetail() + + onView(withId(R.id.cardItemRoot)).perform(click()) + + Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name)) + Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1")) + Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, "Card")) + } + private fun launchBoardDetail(): ActivityScenario { val intent = Intent( androidx.test.core.app.ApplicationProvider.getApplicationContext(), @@ -353,5 +381,24 @@ class BoardDetailFlowTest { ), ) } + + fun detailWithCardTitle(title: String): BoardDetail { + return detailOneList().copy( + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary( + id = "card-1", + title = title, + tags = emptyList(), + dueAtEpochMillis = null, + ), + ), + ), + ), + ) + } } } diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt index fbd6c8e..603ee70 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -46,7 +46,7 @@ class BoardsFlowTest { } @Test - fun boardTapNavigatesToDetailPlaceholderWithExtras() { + fun boardTapNavigatesToBoardDetailActivity() { MainActivity.dependencies.apiClientFactory = { FakeBoardsApiClient( boards = mutableListOf(BoardSummary("1", "Alpha")), @@ -58,9 +58,9 @@ class BoardsFlowTest { onView(withText("Alpha")).perform(click()) - Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name)) - Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, "1")) - Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Alpha")) + Intents.intended(hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name)) + Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_ID, "1")) + Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_TITLE, "Alpha")) } @Test @@ -79,8 +79,8 @@ class BoardsFlowTest { onView(withId(R.id.useTemplateChip)).perform(click()) onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) - Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name)) - Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Roadmap")) + Intents.intended(hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name)) + Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_TITLE, "Roadmap")) } @Test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 14acd5f..69dfb94 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ android:usesCleartextTraffic="true" android:theme="@style/Theme.Kanbn4Droid"> when (event) { is BoardDetailUiEvent.NavigateToCardPlaceholder -> { - Snackbar.make(pager, getString(R.string.board_detail_card_detail_coming_soon), Snackbar.LENGTH_SHORT).show() + val cardTitle = viewModel.uiState.value.boardDetail + ?.lists + .orEmpty() + .asSequence() + .flatMap { list -> list.cards.asSequence() } + .firstOrNull { card -> card.id == event.cardId } + ?.title + .orEmpty() + .trim() + .ifBlank { "Card" } + startActivity( + Intent(this@BoardDetailActivity, CardDetailPlaceholderActivity::class.java) + .putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, event.cardId) + .putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, cardTitle), + ) } is BoardDetailUiEvent.ShowServerError -> { diff --git a/app/src/main/res/layout/activity_card_detail_placeholder.xml b/app/src/main/res/layout/activity_card_detail_placeholder.xml new file mode 100644 index 0000000..7189ea4 --- /dev/null +++ b/app/src/main/res/layout/activity_card_detail_placeholder.xml @@ -0,0 +1,30 @@ + + + + + + + + From a7af727752f78de3ee78dcd1778db01c2b20a8e7 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 01:42:14 -0400 Subject: [PATCH 13/16] fix: use string resources for card placeholder navigation labels --- .../hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt | 5 ++++- .../kanbn4droid/app/CardDetailPlaceholderActivity.kt | 2 +- .../kanbn4droid/app/boarddetail/BoardDetailActivity.kt | 2 +- app/src/main/res/layout/activity_card_detail_placeholder.xml | 2 +- app/src/main/res/values/strings.xml | 3 +++ 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt index f73ad9b..318fb9a 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -6,6 +6,7 @@ import android.view.inputmethod.EditorInfo import android.view.View import android.widget.TextView import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.longClick @@ -267,10 +268,12 @@ class BoardDetailFlowTest { launchBoardDetail() onView(withId(R.id.cardItemRoot)).perform(click()) + val expectedFallback = ApplicationProvider.getApplicationContext() + .getString(R.string.card_detail_placeholder_fallback_title) Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name)) Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1")) - Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, "Card")) + Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback)) } private fun launchBoardDetail(): ActivityScenario { diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/CardDetailPlaceholderActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/CardDetailPlaceholderActivity.kt index ba4db7b..4472643 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/CardDetailPlaceholderActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/CardDetailPlaceholderActivity.kt @@ -14,7 +14,7 @@ class CardDetailPlaceholderActivity : AppCompatActivity() { val cardTitle = intent.getStringExtra(EXTRA_CARD_TITLE).orEmpty() val titleView: TextView = findViewById(R.id.cardDetailPlaceholderTitle) - titleView.text = "$cardTitle\n(id: $cardId)" + titleView.text = getString(R.string.card_detail_placeholder_title, cardTitle, cardId) } companion object { diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt index d9ed9ab..4185b99 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt @@ -168,7 +168,7 @@ class BoardDetailActivity : AppCompatActivity() { ?.title .orEmpty() .trim() - .ifBlank { "Card" } + .ifBlank { getString(R.string.card_detail_placeholder_fallback_title) } startActivity( Intent(this@BoardDetailActivity, CardDetailPlaceholderActivity::class.java) .putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, event.cardId) diff --git a/app/src/main/res/layout/activity_card_detail_placeholder.xml b/app/src/main/res/layout/activity_card_detail_placeholder.xml index 7189ea4..9005c6a 100644 --- a/app/src/main/res/layout/activity_card_detail_placeholder.xml +++ b/app/src/main/res/layout/activity_card_detail_placeholder.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" android:gravity="center" - android:text="@string/board_detail_card_detail_coming_soon" + android:text="@string/card_detail_placeholder_subtitle" android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c0679b..6d1956a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,4 +44,7 @@ Delete selected cards? Are you sure you want to permanently delete the selected cards? Card detail view is coming soon. + %1$s\n(id: %2$s) + Card + Card detail view is coming soon. From e72e584fd4af17234e685e3e9baec2887966b32e Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 02:33:55 -0400 Subject: [PATCH 14/16] feat: add board detail batch move and delete workflows --- .../kanbn4droid/app/BoardDetailFlowTest.kt | 407 +++++++++++++++++- .../app/boarddetail/BoardDetailActivity.kt | 100 ++++- .../app/boarddetail/BoardDetailViewModel.kt | 29 +- app/src/main/res/values/strings.xml | 1 + 4 files changed, 524 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt index 318fb9a..8c4d1b1 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -17,11 +17,13 @@ import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.color.MaterialColors import java.text.DateFormat import java.util.ArrayDeque @@ -53,6 +55,7 @@ class BoardDetailFlowTest { private lateinit var defaultDataSource: FakeBoardDetailDataSource private var originalLocale: Locale? = null + private val observedStates = mutableListOf() @Before fun setUp() { @@ -60,12 +63,14 @@ class BoardDetailFlowTest { Intents.init() defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList()) BoardDetailActivity.testDataSourceFactory = { defaultDataSource } + BoardDetailActivity.testUiStateObserver = { state -> observedStates += state } } @After fun tearDown() { Intents.release() BoardDetailActivity.testDataSourceFactory = null + BoardDetailActivity.testUiStateObserver = null originalLocale?.let { Locale.setDefault(it) } } @@ -251,6 +256,205 @@ class BoardDetailFlowTest { } } + @Test + fun moveDialogShowsListSelector() { + defaultDataSource.currentDetail = detailTwoLists() + launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withContentDescription("Move cards")).perform(click()) + + onView(withText(R.string.move_cards_to_list)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("To Do")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("Doing")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.cancel)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.move_cards)).inRoot(isDialog()).check(matches(isDisplayed())) + } + + @Test + fun moveDisablesWhenTargetAlreadyContainsAllSelected() { + defaultDataSource.currentDetail = detailTwoLists() + launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withContentDescription("Move cards")).perform(click()) + + onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled()))) + } + + @Test + fun moveExcludesNoOpCardsFromPayload() { + defaultDataSource.currentDetail = detailTwoListsWithCardsOnBothPages() + val scenario = launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + scenario.onActivity { activity -> + activity.findViewById(R.id.boardDetailPager).setCurrentItem(1, false) + } + onView(withText("Card 2")).perform(click()) + onView(withContentDescription("Move cards")).perform(click()) + onView(withText("Doing")).inRoot(isDialog()).perform(click()) + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) + + awaitCondition { + defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false + } + + scenario.onActivity { + assertEquals(1, defaultDataSource.moveCalls) + assertEquals("list-2", defaultDataSource.lastMoveTargetListId) + assertEquals(setOf("card-1"), defaultDataSource.lastMoveCardIds) + } + } + + @Test + fun moveConfirmDisabledWhileMutating() { + defaultDataSource.currentDetail = detailTwoLists() + defaultDataSource.moveGate = CompletableDeferred() + launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withContentDescription("Move cards")).perform(click()) + onView(withText("Doing")).inRoot(isDialog()).perform(click()) + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) + + onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled()))) + defaultDataSource.moveGate?.complete(Unit) + } + + @Test + fun movePartialSuccessShowsWarningAndReselectsFailedCards() { + defaultDataSource.currentDetail = detailSingleListTwoCards() + defaultDataSource.moveResult = CardBatchMutationResult.PartialSuccess( + failedCardIds = setOf("card-2"), + message = "Some cards could not be moved. Please try again.", + ) + launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withText("Card 2")).perform(click()) + onView(withContentDescription("Move cards")).perform(click()) + onView(withText("Doing")).inRoot(isDialog()).perform(click()) + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) + + awaitCondition { + defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false + } + + val last = observedStates.last() + assertEquals(setOf("card-2"), last.selectedCardIds) + } + + @Test + fun moveFullFailurePreservesSelectionAndShowsError() { + defaultDataSource.currentDetail = detailTwoLists() + defaultDataSource.moveResult = CardBatchMutationResult.Failure("Move failed") + launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withContentDescription("Move cards")).perform(click()) + onView(withText("Doing")).inRoot(isDialog()).perform(click()) + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) + + awaitCondition { + defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false + } + + val last = observedStates.last() + assertEquals(setOf("card-1"), last.selectedCardIds) + } + + @Test + fun deleteRequiresSecondConfirmation() { + val scenario = launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withContentDescription("Delete cards")).perform(click()) + onView(withText(R.string.delete)).inRoot(isDialog()).perform(click()) + onView(withText(R.string.delete_cards_second_confirmation)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click()) + + scenario.onActivity { + assertEquals(1, defaultDataSource.deleteCalls) + } + } + + @Test + fun deleteConfirmDisabledWhileMutating() { + defaultDataSource.deleteGate = CompletableDeferred() + launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withContentDescription("Delete cards")).perform(click()) + onView(withText(R.string.delete)).inRoot(isDialog()).perform(click()) + onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click()) + + onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled()))) + defaultDataSource.deleteGate?.complete(Unit) + } + + @Test + fun deletePartialSuccessShowsWarningAndReselectsFailedCards() { + defaultDataSource.currentDetail = detailSingleListTwoCards() + defaultDataSource.deleteResult = CardBatchMutationResult.PartialSuccess( + failedCardIds = setOf("card-2"), + message = "Some cards could not be deleted. Please try again.", + ) + launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withText("Card 2")).perform(click()) + onView(withContentDescription("Delete cards")).perform(click()) + onView(withText(R.string.delete)).inRoot(isDialog()).perform(click()) + onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click()) + + awaitCondition { + defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false + } + + val last = observedStates.last() + assertEquals(setOf("card-2"), last.selectedCardIds) + } + + @Test + fun deleteFullFailurePreservesSelectionAndShowsError() { + defaultDataSource.deleteResult = CardBatchMutationResult.Failure("Delete failed") + launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + onView(withContentDescription("Delete cards")).perform(click()) + onView(withText(R.string.delete)).inRoot(isDialog()).perform(click()) + onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click()) + + awaitCondition { + defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false + } + + val last = observedStates.last() + assertEquals(setOf("card-1"), last.selectedCardIds) + } + + @Test + fun selectionPersistsAcrossPagesAndSelectAllIsPageScoped() { + defaultDataSource.currentDetail = detailTwoListsTwoCardsEach() + val scenario = launchBoardDetail() + + onView(withText("Todo A")).perform(longClick()) + scenario.onActivity { activity -> + activity.findViewById(R.id.boardDetailPager).setCurrentItem(1, false) + } + onView(withContentDescription("Select all")).perform(click()) + onView(withContentDescription("Move cards")).perform(click()) + onView(withText("To Do")).inRoot(isDialog()).perform(click()) + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) + + scenario.onActivity { + assertEquals(1, defaultDataSource.moveCalls) + assertEquals("list-1", defaultDataSource.lastMoveTargetListId) + assertEquals(setOf("doing-a", "doing-b"), defaultDataSource.lastMoveCardIds) + } + } + @Test fun cardTapNavigatesToCardPlaceholderWithExtras() { launchBoardDetail() @@ -300,6 +504,19 @@ class BoardDetailFlowTest { } } + private fun awaitCondition(timeoutMs: Long = 4_000L, condition: () -> Boolean) { + val instrumentation = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + val start = System.currentTimeMillis() + while (System.currentTimeMillis() - start < timeoutMs) { + instrumentation.waitForIdleSync() + if (condition()) { + return + } + Thread.sleep(50) + } + throw AssertionError("Condition not met within ${timeoutMs}ms") + } + private class FakeBoardDetailDataSource( initialDetail: BoardDetail, ) : BoardDetailDataSource { @@ -310,6 +527,15 @@ class BoardDetailFlowTest { var renameGate: CompletableDeferred? = null var renameCalls: Int = 0 var lastRenameTitle: String? = null + var moveResult: CardBatchMutationResult = CardBatchMutationResult.Success + var deleteResult: CardBatchMutationResult = CardBatchMutationResult.Success + var moveGate: CompletableDeferred? = null + var deleteGate: CompletableDeferred? = null + var moveCalls: Int = 0 + var deleteCalls: Int = 0 + var lastMoveCardIds: Set = emptySet() + var lastDeleteCardIds: Set = emptySet() + var lastMoveTargetListId: String? = null override suspend fun getBoardDetail(boardId: String): BoardsApiResult { loadGate?.await() @@ -321,11 +547,52 @@ class BoardDetailFlowTest { } override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { - return CardBatchMutationResult.Success + moveCalls += 1 + lastMoveCardIds = cardIds.toSet() + lastMoveTargetListId = targetListId + moveGate?.await() + + val result = moveResult + if (result is CardBatchMutationResult.Success || result is CardBatchMutationResult.PartialSuccess) { + val failedIds = if (result is CardBatchMutationResult.PartialSuccess) result.failedCardIds else emptySet() + val movedIds = cardIds.filterNot { failedIds.contains(it) }.toSet() + if (movedIds.isNotEmpty()) { + val targetList = currentDetail.lists.firstOrNull { it.id == targetListId } + if (targetList != null) { + val movedCards = currentDetail.lists.flatMap { it.cards }.filter { movedIds.contains(it.id) } + currentDetail = currentDetail.copy( + lists = currentDetail.lists.map { list -> + if (list.id == targetList.id) { + list.copy(cards = list.cards + movedCards.filter { moved -> list.cards.none { it.id == moved.id } }) + } else { + list.copy(cards = list.cards.filterNot { movedIds.contains(it.id) }) + } + }, + ) + } + } + } + return result } override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { - return CardBatchMutationResult.Success + deleteCalls += 1 + lastDeleteCardIds = cardIds.toSet() + deleteGate?.await() + + val result = deleteResult + if (result is CardBatchMutationResult.Success || result is CardBatchMutationResult.PartialSuccess) { + val failedIds = if (result is CardBatchMutationResult.PartialSuccess) result.failedCardIds else emptySet() + val deletedIds = cardIds.filterNot { failedIds.contains(it) }.toSet() + if (deletedIds.isNotEmpty()) { + currentDetail = currentDetail.copy( + lists = currentDetail.lists.map { list -> + list.copy(cards = list.cards.filterNot { deletedIds.contains(it.id) }) + }, + ) + } + } + return result } override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { @@ -403,5 +670,141 @@ class BoardDetailFlowTest { ), ) } + + fun detailTwoLists(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary( + id = "card-1", + title = "Card 1", + tags = emptyList(), + dueAtEpochMillis = null, + ), + ), + ), + BoardListDetail( + id = "list-2", + title = "Doing", + cards = emptyList(), + ), + ), + ) + } + + fun detailTwoListsWithCardsOnBothPages(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary( + id = "card-1", + title = "Card 1", + tags = emptyList(), + dueAtEpochMillis = null, + ), + ), + ), + BoardListDetail( + id = "list-2", + title = "Doing", + cards = listOf( + BoardCardSummary( + id = "card-2", + title = "Card 2", + tags = emptyList(), + dueAtEpochMillis = null, + ), + ), + ), + ), + ) + } + + fun detailSingleListTwoCards(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary( + id = "card-1", + title = "Card 1", + tags = emptyList(), + dueAtEpochMillis = null, + ), + BoardCardSummary( + id = "card-2", + title = "Card 2", + tags = emptyList(), + dueAtEpochMillis = null, + ), + ), + ), + BoardListDetail( + id = "list-2", + title = "Doing", + cards = emptyList(), + ), + ), + ) + } + + fun detailTwoListsTwoCardsEach(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary( + id = "todo-a", + title = "Todo A", + tags = emptyList(), + dueAtEpochMillis = null, + ), + BoardCardSummary( + id = "todo-b", + title = "Todo B", + tags = emptyList(), + dueAtEpochMillis = null, + ), + ), + ), + BoardListDetail( + id = "list-2", + title = "Doing", + cards = listOf( + BoardCardSummary( + id = "doing-a", + title = "Doing A", + tags = emptyList(), + dueAtEpochMillis = null, + ), + BoardCardSummary( + id = "doing-b", + title = "Doing B", + tags = emptyList(), + dueAtEpochMillis = null, + ), + ), + ), + ), + ) + } } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt index 4185b99..305315c 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt @@ -9,6 +9,7 @@ import android.widget.Button import android.widget.ProgressBar import android.widget.TextView import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView @@ -43,6 +44,10 @@ class BoardDetailActivity : AppCompatActivity() { private lateinit var retryButton: Button private var inlineTitleErrorMessage: String? = null + private var moveDialog: AlertDialog? = null + private var deleteSecondConfirmationDialog: AlertDialog? = null + private var dismissMoveDialogWhenMutationEnds: Boolean = false + private var dismissDeleteDialogWhenMutationEnds: Boolean = false private lateinit var pagerAdapter: BoardListsPagerAdapter @@ -197,6 +202,7 @@ class BoardDetailActivity : AppCompatActivity() { } private fun render(state: BoardDetailUiState) { + testUiStateObserver?.invoke(state) supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty() fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) { @@ -239,6 +245,54 @@ class BoardDetailActivity : AppCompatActivity() { } renderSelectionActions(state) + renderOpenDialogs(state) + } + + private fun renderOpenDialogs(state: BoardDetailUiState) { + val activeMoveDialog = moveDialog + if (activeMoveDialog != null) { + val lists = state.boardDetail?.lists.orEmpty() + if (lists.isEmpty()) { + activeMoveDialog.dismiss() + moveDialog = null + } else { + val selectedIndex = activeMoveDialog.listView?.checkedItemPosition + ?.takeIf { it in lists.indices } + ?: state.currentPageIndex.coerceIn(0, lists.lastIndex) + val targetList = lists[selectedIndex] + val targetIds = targetList.cards.map { it.id }.toSet() + val canMove = !state.isMutating && (state.selectedCardIds - targetIds).isNotEmpty() + + activeMoveDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = canMove + activeMoveDialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating + + if (dismissMoveDialogWhenMutationEnds && !state.isMutating) { + activeMoveDialog.dismiss() + moveDialog = null + dismissMoveDialogWhenMutationEnds = false + } + + if (!state.isMutating && state.selectedCardIds.isEmpty()) { + activeMoveDialog.dismiss() + moveDialog = null + } + } + } + + val activeDeleteDialog = deleteSecondConfirmationDialog + if (activeDeleteDialog != null) { + activeDeleteDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating + activeDeleteDialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating + if (dismissDeleteDialogWhenMutationEnds && !state.isMutating) { + activeDeleteDialog.dismiss() + deleteSecondConfirmationDialog = null + dismissDeleteDialogWhenMutationEnds = false + } + if (!state.isMutating && state.selectedCardIds.isEmpty()) { + activeDeleteDialog.dismiss() + deleteSecondConfirmationDialog = null + } + } } private fun renderSelectionActions(state: BoardDetailUiState) { @@ -282,31 +336,62 @@ class BoardDetailActivity : AppCompatActivity() { if (lists.isEmpty()) { return } + val listNames = lists.map { it.title }.toTypedArray() var selectedIndex = viewModel.uiState.value.currentPageIndex.coerceIn(0, lists.lastIndex) - MaterialAlertDialogBuilder(this) + val dialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.move_cards_to_list) - .setSingleChoiceItems(listNames, selectedIndex) { _, which -> selectedIndex = which } + .setSingleChoiceItems(listNames, selectedIndex) { _, which -> + selectedIndex = which + renderOpenDialogs(viewModel.uiState.value) + } .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.move_cards) { _, _ -> + .setPositiveButton(R.string.move_cards, null) + .create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { val targetId = lists[selectedIndex].id + dismissMoveDialogWhenMutationEnds = true viewModel.moveSelectedCards(targetId) } - .show() + renderOpenDialogs(viewModel.uiState.value) + } + dialog.setOnDismissListener { + if (moveDialog === dialog) { + moveDialog = null + } + dismissMoveDialogWhenMutationEnds = false + } + moveDialog = dialog + dialog.show() } private fun showDeleteCardsDialog() { MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_cards_title) .setMessage(R.string.delete_cards_confirmation) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.delete) { _, _ -> - MaterialAlertDialogBuilder(this) + val secondDialog = MaterialAlertDialogBuilder(this) .setMessage(R.string.delete_cards_second_confirmation) .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.im_sure) { _, _ -> + .setPositiveButton(R.string.im_sure, null) + .create() + secondDialog.setOnShowListener { + secondDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { + dismissDeleteDialogWhenMutationEnds = true viewModel.deleteSelectedCards() } - .show() + renderOpenDialogs(viewModel.uiState.value) + } + secondDialog.setOnDismissListener { + if (deleteSecondConfirmationDialog === secondDialog) { + deleteSecondConfirmationDialog = null + } + dismissDeleteDialogWhenMutationEnds = false + } + deleteSecondConfirmationDialog = secondDialog + secondDialog.show() } .show() } @@ -329,5 +414,6 @@ class BoardDetailActivity : AppCompatActivity() { const val EXTRA_BOARD_TITLE = "extra_board_title" var testDataSourceFactory: ((String) -> BoardDetailDataSource)? = null + var testUiStateObserver: ((BoardDetailUiState) -> Unit)? = null } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt index fa4368a..2641eca 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt @@ -130,11 +130,32 @@ class BoardDetailViewModel( } fun moveSelectedCards(targetListId: String) { - runMutation { selectedIds -> repository.moveCards(selectedIds, targetListId) } + val snapshot = _uiState.value + if (snapshot.isMutating) { + return + } + val selectedIds = snapshot.selectedCardIds + if (selectedIds.isEmpty()) { + return + } + + val targetCardIds = snapshot.boardDetail + ?.lists + ?.firstOrNull { it.id == targetListId } + ?.cards + .orEmpty() + .map { it.id } + .toSet() + val movableIds = selectedIds - targetCardIds + if (movableIds.isEmpty()) { + return + } + + runMutation(selectedIds = movableIds) { ids -> repository.moveCards(ids, targetListId) } } fun deleteSelectedCards() { - runMutation(repository::deleteCards) + runMutation(selectedIds = _uiState.value.selectedCardIds, mutation = repository::deleteCards) } fun startEditingList(listId: String) { @@ -238,14 +259,14 @@ class BoardDetailViewModel( } private fun runMutation( + selectedIds: Set, mutation: suspend (Set) -> CardBatchMutationResult, ) { val preMutation = _uiState.value if (preMutation.isMutating) { return } - val selectedIds = preMutation.selectedCardIds - if (selectedIds.isEmpty()) { + if (preMutation.selectedCardIds.isEmpty() || selectedIds.isEmpty()) { return } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d1956a..4451843 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,7 @@ Select all Move cards Delete cards + Delete selected cards Move cards to list Delete selected cards? Are you sure you want to permanently delete the selected cards? From 81cd654611901e7ae3dc8c679f75d7630596665c Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 03:15:31 -0400 Subject: [PATCH 15/16] fix: finalize board detail action icons and startup guards --- .../kanbn4droid/app/BoardDetailFlowTest.kt | 113 +++++++++++++++++- .../app/boarddetail/BoardDetailActivity.kt | 29 ++++- .../app/boarddetail/BoardDetailRepository.kt | 8 +- app/src/main/res/drawable/ic_delete_24.xml | 12 ++ .../drawable/ic_move_cards_horizontal_24.xml | 12 ++ .../res/drawable/ic_select_all_grid_24.xml | 12 ++ .../res/menu/menu_board_detail_selection.xml | 6 +- app/src/main/res/values/strings.xml | 2 + 8 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/drawable/ic_delete_24.xml create mode 100644 app/src/main/res/drawable/ic_move_cards_horizontal_24.xml create mode 100644 app/src/main/res/drawable/ic_select_all_grid_24.xml diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt index 8c4d1b1..8b06a6b 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -5,6 +5,7 @@ import android.graphics.Color import android.view.inputmethod.EditorInfo import android.view.View import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView @@ -17,7 +18,6 @@ import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.withContentDescription -import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withId @@ -38,6 +38,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -48,6 +49,8 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailDataSource import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.SessionStore import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @RunWith(AndroidJUnit4::class) @@ -59,6 +62,9 @@ class BoardDetailFlowTest { @Before fun setUp() { + MainActivity.dependencies.clear() + MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") } + MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") } originalLocale = Locale.getDefault() Intents.init() defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList()) @@ -71,6 +77,7 @@ class BoardDetailFlowTest { Intents.release() BoardDetailActivity.testDataSourceFactory = null BoardDetailActivity.testUiStateObserver = null + MainActivity.dependencies.clear() originalLocale?.let { Locale.setDefault(it) } } @@ -256,6 +263,59 @@ class BoardDetailFlowTest { } } + @Test + fun selectionActionIconsMatchExpectedResources() { + val scenario = launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + + scenario.onActivity { activity -> + val menu = activity.findViewById(R.id.boardDetailToolbar).menu + assertNotNull(menu.findItem(R.id.actionSelectAll)?.icon) + assertNotNull(menu.findItem(R.id.actionMoveCards)?.icon) + assertNotNull(menu.findItem(R.id.actionDeleteCards)?.icon) + + assertEquals( + AppCompatResources.getDrawable(activity, R.drawable.ic_select_all_grid_24)?.constantState, + menu.findItem(R.id.actionSelectAll)?.icon?.constantState, + ) + assertEquals( + AppCompatResources.getDrawable(activity, R.drawable.ic_move_cards_horizontal_24)?.constantState, + menu.findItem(R.id.actionMoveCards)?.icon?.constantState, + ) + assertEquals( + AppCompatResources.getDrawable(activity, R.drawable.ic_delete_24)?.constantState, + menu.findItem(R.id.actionDeleteCards)?.icon?.constantState, + ) + } + } + + @Test + fun missingBoardIdShowsBlockingDialogAndFinishes() { + val scenario = launchBoardDetail(boardId = null) + + onView(withText(R.string.board_detail_unable_to_open_board)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.ok)).inRoot(isDialog()).perform(click()) + scenario.onActivity { activity -> + assertTrue(activity.isFinishing) + } + } + + @Test + fun missingSessionShowsBlockingDialogAndFinishes() { + BoardDetailActivity.testDataSourceFactory = null + MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore(null) } + MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore(null) } + + val scenario = launchBoardDetail() + + onView(withText(R.string.board_detail_session_expired)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.ok)).inRoot(isDialog()).perform(click()) + scenario.onActivity { activity -> + assertTrue(activity.isFinishing) + } + } + @Test fun moveDialogShowsListSelector() { defaultDataSource.currentDetail = detailTwoLists() @@ -341,6 +401,7 @@ class BoardDetailFlowTest { defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false } + onView(withText("Some cards could not be moved. Please try again.")).check(matches(isDisplayed())) val last = observedStates.last() assertEquals(setOf("card-2"), last.selectedCardIds) } @@ -360,6 +421,7 @@ class BoardDetailFlowTest { defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false } + onView(withText("Move failed")).check(matches(isDisplayed())) val last = observedStates.last() assertEquals(setOf("card-1"), last.selectedCardIds) } @@ -412,6 +474,7 @@ class BoardDetailFlowTest { defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false } + onView(withText("Some cards could not be deleted. Please try again.")).check(matches(isDisplayed())) val last = observedStates.last() assertEquals(setOf("card-2"), last.selectedCardIds) } @@ -430,6 +493,7 @@ class BoardDetailFlowTest { defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false } + onView(withText("Delete failed")).check(matches(isDisplayed())) val last = observedStates.last() assertEquals(setOf("card-1"), last.selectedCardIds) } @@ -480,12 +544,15 @@ class BoardDetailFlowTest { Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback)) } - private fun launchBoardDetail(): ActivityScenario { + private fun launchBoardDetail(boardId: String? = "board-1"): ActivityScenario { val intent = Intent( androidx.test.core.app.ApplicationProvider.getApplicationContext(), BoardDetailActivity::class.java, - ).putExtra(BoardDetailActivity.EXTRA_BOARD_ID, "board-1") - .putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, "Board") + ) + if (boardId != null) { + intent.putExtra(BoardDetailActivity.EXTRA_BOARD_ID, boardId) + } + intent.putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, "Board") return ActivityScenario.launch(intent) } @@ -611,6 +678,44 @@ class BoardDetailFlowTest { } } + private class InMemorySessionStore( + private var baseUrl: String?, + ) : SessionStore { + override fun getBaseUrl(): String? = baseUrl + + override fun saveBaseUrl(url: String) { + baseUrl = url + } + + override fun clearBaseUrl() { + baseUrl = null + } + + override fun getWorkspaceId(): String? = "ws-1" + + override fun saveWorkspaceId(workspaceId: String) { + } + + override fun clearWorkspaceId() { + } + } + + private class InMemoryApiKeyStore( + private var key: String?, + ) : ApiKeyStore { + override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { + key = apiKey + return Result.success(Unit) + } + + override suspend fun getApiKey(baseUrl: String): Result = Result.success(key) + + override suspend fun invalidateApiKey(baseUrl: String): Result { + key = null + return Result.success(Unit) + } + } + private companion object { fun detailOneList(): BoardDetail { return BoardDetail( diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt index 305315c..cb29cb3 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt @@ -48,6 +48,7 @@ class BoardDetailActivity : AppCompatActivity() { private var deleteSecondConfirmationDialog: AlertDialog? = null private var dismissMoveDialogWhenMutationEnds: Boolean = false private var dismissDeleteDialogWhenMutationEnds: Boolean = false + private var hasShownBlockingStartupError: Boolean = false private lateinit var pagerAdapter: BoardListsPagerAdapter @@ -90,6 +91,11 @@ class BoardDetailActivity : AppCompatActivity() { setupPager() observeViewModel() + if (boardId.isBlank()) { + showBlockingStartupErrorAndFinish(getString(R.string.board_detail_unable_to_open_board)) + return + } + viewModel.loadBoardDetail() } @@ -205,6 +211,15 @@ class BoardDetailActivity : AppCompatActivity() { testUiStateObserver?.invoke(state) supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty() + if ( + !hasShownBlockingStartupError && + state.boardDetail == null && + state.fullScreenErrorMessage == BoardDetailRepository.MISSING_SESSION_MESSAGE + ) { + showBlockingStartupErrorAndFinish(getString(R.string.board_detail_session_expired)) + return + } + fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) { fullScreenErrorText.text = state.fullScreenErrorMessage View.VISIBLE @@ -248,6 +263,17 @@ class BoardDetailActivity : AppCompatActivity() { renderOpenDialogs(state) } + private fun showBlockingStartupErrorAndFinish(message: String) { + hasShownBlockingStartupError = true + MaterialAlertDialogBuilder(this) + .setMessage(message) + .setCancelable(false) + .setPositiveButton(R.string.ok) { _, _ -> + finish() + } + .show() + } + private fun renderOpenDialogs(state: BoardDetailUiState) { val activeMoveDialog = moveDialog if (activeMoveDialog != null) { @@ -350,7 +376,8 @@ class BoardDetailActivity : AppCompatActivity() { .create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { - val targetId = lists[selectedIndex].id + val currentLists = viewModel.uiState.value.boardDetail?.lists.orEmpty() + val targetId = currentLists.getOrNull(selectedIndex)?.id ?: return@setOnClickListener dismissMoveDialogWhenMutationEnds = true viewModel.moveSelectedCards(targetId) } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt index 0e9dc6e..21ee759 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt @@ -14,6 +14,10 @@ class BoardDetailRepository( private val apiClient: KanbnApiClient, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { + companion object { + const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again." + } + suspend fun getBoardDetail(boardId: String): BoardsApiResult { val normalizedBoardId = boardId.trim() if (normalizedBoardId.isBlank()) { @@ -154,11 +158,11 @@ class BoardDetailRepository( private suspend fun session(): BoardsApiResult { val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } - ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + ?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE) val apiKey = withContext(ioDispatcher) { apiKeyStore.getApiKey(baseUrl) }.getOrNull()?.takeIf { it.isNotBlank() } - ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + ?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE) val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) { is BoardsApiResult.Success -> workspaceResult.value diff --git a/app/src/main/res/drawable/ic_delete_24.xml b/app/src/main/res/drawable/ic_delete_24.xml new file mode 100644 index 0000000..0118475 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_24.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_move_cards_horizontal_24.xml b/app/src/main/res/drawable/ic_move_cards_horizontal_24.xml new file mode 100644 index 0000000..5cddffd --- /dev/null +++ b/app/src/main/res/drawable/ic_move_cards_horizontal_24.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_select_all_grid_24.xml b/app/src/main/res/drawable/ic_select_all_grid_24.xml new file mode 100644 index 0000000..debd459 --- /dev/null +++ b/app/src/main/res/drawable/ic_select_all_grid_24.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/menu/menu_board_detail_selection.xml b/app/src/main/res/menu/menu_board_detail_selection.xml index 09f4048..be75a73 100644 --- a/app/src/main/res/menu/menu_board_detail_selection.xml +++ b/app/src/main/res/menu/menu_board_detail_selection.xml @@ -4,19 +4,19 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4451843..2b74c26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,4 +48,6 @@ %1$s\n(id: %2$s) Card Card detail view is coming soon. + Unable to open board. + Session expired. Please sign in again. From 235ab9973ca499db2e6fe209df5dae1aa2e72146 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 03:15:33 -0400 Subject: [PATCH 16/16] docs: update agent guide with board detail implementation status --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 604415f..260dd03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete". - Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure". - Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API. -- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailPlaceholderActivity` while full board detail is still pending. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation. +- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailActivity`. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation. **Board detail view** @@ -98,6 +98,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure". - Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API. - Long-pressing any of the buttons must show a tooltip with the button name. +- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`). Startup blocking dialogs are shown for missing board id and missing session. **Card detail view** - The view shows the card's title in bold letters. Tapping on the card's title allows editing it. @@ -111,6 +112,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - The modal dialog has "Add comment" as a title. - The modal dialog has an editable markdown-enabled text field for the comment. - The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively. +- Current status: full card detail is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras. **Settings view** - The view shows a list of settings that can be changed by the user. The following settings are available: