fix board-detail card move compatibility across API variants

This commit is contained in:
2026-03-16 12:24:35 -04:00
parent 4246d01827
commit 6c67628e40
3 changed files with 284 additions and 15 deletions

View File

@@ -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.

View File

@@ -276,12 +276,13 @@ class HttpKanbnApiClient : KanbnApiClient {
targetListId: String,
): BoardsApiResult<Unit> {
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,7 +290,120 @@ 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(
@@ -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 {

View File

@@ -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<CapturedRequest>()
private val responses = mutableMapOf<String, Pair<Int, String>>()
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
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<Pair<Int, String>>) {
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<CapturedRequest> {
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)
}
}