diff --git a/AGENTS.md b/AGENTS.md index fbe5909..e2a985e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,23 @@ 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`. Label chip border colors are hydrated from the Kan.bn `Get a label by public ID` endpoint (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night resource variants so dark mode uses light icon fills automatically. Startup blocking dialogs are shown for missing board id and missing session. +- The view has a floating + button that shows a modal dialog that allows creating a new card using the Kan.bn API. + - The new card is added to the top of the currently shown list. + - The modal dialog has a field for the card's name. This field is mandatory + - Below the card name field there is a markdown-enabled text area for an optional card description. + - Below the card description field there is an optional date field to set the card's due date. + - Below the card's due date field there is an optional multi-value selector that allows choosing the card's tags from the tags available for the current board. +- The title bar of the view has two icon-only buttons for "Filter by tag" (icon is three bars of decreasing width, widest on top) and "Search" (icon is a leaning looking glass) + - The filter by tag button opens a modal dialog that shows a multi-value selector that allows choosing from the tags available on the current board. The modal has a title that says "Filter by tag". The modal has buttons for "Cancel" and "Filter". + - The search button a modal dialog that shows a text field that has the placeholder value "Search". The modal has a title that seas "Search by title". The modal has buttons for "Cancel" and "Search". + - Applying a filter or search makes the active board show only the cards that match the given criteria (selected tags or matching title). + - The filters are applied locally without contacting the server. + - The search by title filter matches any part of the title. Example: searching for "Duke" matches "Duke Nukem" as well as "Nukem Duke" + - When a filter by tag or search is applied the corresponding button in the title bar gets highlighted. + - Tapping on the filter by tag or search buttonswhen either of them is applied disables the active filter. +- When a card(s) is selected by a long press, the filter by tag and search buttons get hidden by the select all, move card and delete card buttons until all cards are deselected. +- When a card(s) is selected by a long press, the back arrow in the title bar and the back system button remove all selections. +- 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`. Card move requests try these variants in order for compatibility across Kan.bn API versions: `PUT /api/v1/cards/{cardPublicId}` with `listPublicId`, then a GET+full-body `PUT /api/v1/cards/{cardPublicId}` payload (`title`, `description`, `index`, `listPublicId`, `dueDate`), then `PUT /api/v1/cards/{cardPublicId}` with `listId`, then `PATCH /api/v1/cards/{cardPublicId}` with `listId`. Board detail parsing now prefers public ids (`publicId`/`public_id`) over internal `id` values so follow-up card/list mutations target the correct API identifiers. Label chip border colors are hydrated from the Kan.bn `Get a label by public ID` endpoint (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night resource variants so dark mode uses light icon fills automatically. 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. 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 89764c0..93cb056 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 @@ -276,12 +276,13 @@ class HttpKanbnApiClient : KanbnApiClient { targetListId: String, ): BoardsApiResult { return withContext(Dispatchers.IO) { - request( + val putListPublicIdBody = "{\"listPublicId\":\"${jsonEscape(targetListId)}\"}" + val putResult = request( baseUrl = baseUrl, path = "/api/v1/cards/$cardId", - method = "PATCH", + method = "PUT", apiKey = apiKey, - body = "{\"listId\":\"${jsonEscape(targetListId)}\"}", + body = putListPublicIdBody, ) { code, body -> if (code in 200..299) { BoardsApiResult.Success(Unit) @@ -289,9 +290,122 @@ class HttpKanbnApiClient : KanbnApiClient { BoardsApiResult.Failure(serverMessage(body, code)) } } + + if (putResult is BoardsApiResult.Success) { + return@withContext putResult + } + + val cardResponse = request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "GET", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(body) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + if (cardResponse is BoardsApiResult.Success) { + val fullUpdateBody = buildFullCardMovePayload(cardResponse.value, targetListId) + if (fullUpdateBody != null) { + val putFullResult = request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "PUT", + apiKey = apiKey, + body = fullUpdateBody, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + if (putFullResult is BoardsApiResult.Success) { + return@withContext putFullResult + } + } + } + + val putListIdBody = "{\"listId\":\"${jsonEscape(targetListId)}\"}" + val putWithListIdResult = request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "PUT", + apiKey = apiKey, + body = putListIdBody, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + + if (putWithListIdResult is BoardsApiResult.Success) { + return@withContext putWithListIdResult + } + + val patchResult = request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "PATCH", + apiKey = apiKey, + body = putListIdBody, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + + if (patchResult is BoardsApiResult.Success) { + return@withContext patchResult + } + + putWithListIdResult } } + private fun buildFullCardMovePayload(cardBody: String, targetListId: String): String? { + if (cardBody.isBlank()) { + return null + } + + val root = parseJsonObject(cardBody) ?: return null + val card = (root["card"] as? Map<*, *>) ?: root + val title = extractString(card, "title", "name") + if (title.isBlank()) { + return null + } + + val description = firstPresent(card, "description", "body")?.toString().orEmpty() + val indexValue = firstPresent(card, "index", "position") + val indexNumber = when (indexValue) { + is Number -> indexValue + is String -> indexValue.toDoubleOrNull() + else -> null + } + val dueValue = firstPresent(card, "dueDate", "dueAt", "due_at", "due")?.toString()?.trim().orEmpty() + val dueField = if (dueValue.isBlank() || dueValue.equals("null", ignoreCase = true)) { + "null" + } else { + "\"${jsonEscape(dueValue)}\"" + } + val indexField = indexNumber?.toString() ?: "0" + + return "{" + + "\"title\":\"${jsonEscape(title)}\"," + + "\"description\":\"${jsonEscape(description)}\"," + + "\"index\":$indexField," + + "\"listPublicId\":\"${jsonEscape(targetListId)}\"," + + "\"dueDate\":$dueField" + + "}" + } + override suspend fun deleteCard( baseUrl: String, apiKey: String, @@ -615,11 +729,11 @@ class HttpKanbnApiClient : KanbnApiClient { } private fun extractId(source: Map<*, *>): String { - val directId = source["id"]?.toString().orEmpty() - if (directId.isNotBlank()) { - return directId + val publicId = extractString(source, "publicId", "public_id") + if (publicId.isNotBlank()) { + return publicId } - return extractString(source, "publicId", "public_id") + return source["id"]?.toString().orEmpty() } private fun extractTitle(source: Map<*, *>, fallback: String): String { 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 0cb1d89..4949cd5 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,50 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun getBoardDetailPrefersPublicIdsWhenBothIdFormsExist() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/board-public", + status = 200, + responseBody = + """ + { + "id": "board-internal", + "publicId": "board-public", + "title": "Board", + "lists": [ + { + "id": "list-internal", + "public_id": "list-public", + "title": "List", + "cards": [ + { + "id": "card-internal", + "publicId": "card-public", + "title": "Card", + "labels": [ + {"id": "tag-internal", "publicId": "tag-public", "name": "Tag", "color": "#111111"} + ] + } + ] + } + ] + } + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "board-public") + + assertTrue(result is BoardsApiResult.Success<*>) + val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail + assertEquals("board-public", detail.id) + assertEquals("list-public", detail.lists[0].id) + assertEquals("card-public", detail.lists[0].cards[0].id) + assertEquals("tag-public", detail.lists[0].cards[0].tags[0].id) + } + } + @Test fun getBoardDetailParsesPlainDataWrapperWithoutBoardKey() = runTest { TestServer().use { server -> @@ -237,9 +281,9 @@ class HttpKanbnApiClientBoardDetailParsingTest { assertEquals("{\"name\":\"New title\"}", renameRequest?.body) assertEquals("api-123", renameRequest?.apiKey) - val moveRequest = server.findRequest("PATCH", "/api/v1/cards/c-1") + val moveRequest = server.findRequest("PUT", "/api/v1/cards/c-1") assertNotNull(moveRequest) - assertEquals("{\"listId\":\"l-9\"}", moveRequest?.body) + assertEquals("{\"listPublicId\":\"l-9\"}", moveRequest?.body) assertEquals("api-123", moveRequest?.apiKey) val deleteRequest = server.findRequest("DELETE", "/api/v1/cards/c-2") @@ -316,6 +360,84 @@ class HttpKanbnApiClientBoardDetailParsingTest { } } + @Test + fun moveCardFallsBackToPutListIdThenPatchWhenPutListPublicIdFails() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/cards/c-fallback-move", + method = "PUT", + status = 400, + responseBody = """{"message":"Failed to update card"}""", + ) + server.register( + path = "/api/v1/cards/c-fallback-move", + method = "PATCH", + status = 200, + responseBody = "{}", + ) + + val result = HttpKanbnApiClient().moveCard( + baseUrl = server.baseUrl, + apiKey = "api", + cardId = "c-fallback-move", + targetListId = "l-target", + ) + + assertTrue(result is BoardsApiResult.Success<*>) + val putRequests = server.findRequests("PUT", "/api/v1/cards/c-fallback-move") + val patchRequest = server.findRequest("PATCH", "/api/v1/cards/c-fallback-move") + assertEquals(2, putRequests.size) + assertNotNull(patchRequest) + assertEquals("{\"listPublicId\":\"l-target\"}", putRequests[0].body) + assertEquals("{\"listId\":\"l-target\"}", putRequests[1].body) + assertEquals("{\"listId\":\"l-target\"}", patchRequest?.body) + } + } + + @Test + fun moveCardUsesFullPutPayloadWhenMinimalPayloadFails() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/cards/c-full", + method = "PUT", + status = 500, + responseBody = """{"message":"Failed to update card"}""", + ) + server.register( + path = "/api/v1/cards/c-full", + method = "GET", + status = 200, + responseBody = """{"publicId":"c-full","title":"Card title","description":"Desc","dueDate":null}""", + ) + server.registerSequence( + path = "/api/v1/cards/c-full", + method = "PUT", + responses = listOf( + 500 to """{"message":"Failed to update card"}""", + 200 to "{}", + ), + ) + + val result = HttpKanbnApiClient().moveCard( + baseUrl = server.baseUrl, + apiKey = "api", + cardId = "c-full", + targetListId = "l-target", + ) + + assertTrue(result is BoardsApiResult.Success<*>) + val putRequests = server.findRequests("PUT", "/api/v1/cards/c-full") + assertTrue(putRequests.size >= 2) + assertEquals("{\"listPublicId\":\"l-target\"}", putRequests[0].body) + assertEquals( + "{\"title\":\"Card title\",\"description\":\"Desc\",\"index\":0,\"listPublicId\":\"l-target\",\"dueDate\":null}", + putRequests[1].body, + ) + val getRequest = server.findRequest("GET", "/api/v1/cards/c-full") + assertNotNull(getRequest) + } + } + @Test fun deleteCardFailureUsesServerMessageAndFallback() = runTest { TestServer().use { server -> @@ -429,6 +551,7 @@ class HttpKanbnApiClientBoardDetailParsingTest { private class TestServer : AutoCloseable { private val requests = CopyOnWriteArrayList() private val responses = mutableMapOf>() + private val responseSequences = mutableMapOf>>() private val running = AtomicBoolean(true) private val serverSocket = ServerSocket().apply { bind(InetSocketAddress("127.0.0.1", 0)) @@ -454,16 +577,29 @@ class HttpKanbnApiClientBoardDetailParsingTest { } 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 + register(path = path, method = "GET", status = status, responseBody = responseBody) + register(path = path, method = "PATCH", status = status, responseBody = responseBody) + register(path = path, method = "PUT", status = status, responseBody = responseBody) + register(path = path, method = "DELETE", status = status, responseBody = responseBody) + register(path = path, method = "POST", status = status, responseBody = responseBody) + } + + fun register(path: String, method: String, status: Int, responseBody: String) { + responses["${method.uppercase()} $path"] = status to responseBody + } + + fun registerSequence(path: String, method: String, responses: List>) { + responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses) } fun findRequest(method: String, path: String): CapturedRequest? { return requests.firstOrNull { it.method == method && it.path == path } } + fun findRequests(method: String, path: String): List { + return requests.filter { it.method == method && it.path == path } + } + private fun handle(socket: Socket) { socket.use { s -> s.soTimeout = 3_000 @@ -516,7 +652,10 @@ class HttpKanbnApiClientBoardDetailParsingTest { val effectiveMethod = methodOverride ?: method requests += CapturedRequest(method = effectiveMethod, path = path, body = body, apiKey = apiKey) - val response = responses["$effectiveMethod $path"] ?: responses["$method $path"] ?: (404 to "") + val sequenceKey = "$effectiveMethod $path" + val sequence = responseSequences[sequenceKey] + val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null + val response = sequencedResponse ?: responses[sequenceKey] ?: responses["$method $path"] ?: (404 to "") writeResponse(output, response.first, response.second) } }