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 93cb056..43b65ad 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 @@ -12,6 +12,7 @@ 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.boarddetail.CreatedEntityRef import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @@ -62,6 +63,28 @@ interface KanbnApiClient { return BoardsApiResult.Failure("List rename is not implemented.") } + suspend fun createList( + baseUrl: String, + apiKey: String, + boardPublicId: String, + title: String, + appendIndex: Int, + ): BoardsApiResult { + return BoardsApiResult.Failure("List creation is not implemented.") + } + + suspend fun createCard( + baseUrl: String, + apiKey: String, + listPublicId: String, + title: String, + description: String?, + dueDate: String?, + tagPublicIds: List, + ): BoardsApiResult { + return BoardsApiResult.Failure("Card creation is not implemented.") + } + suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult { return BoardsApiResult.Failure("Card move is not implemented.") } @@ -269,6 +292,71 @@ class HttpKanbnApiClient : KanbnApiClient { } } + override suspend fun createList( + baseUrl: String, + apiKey: String, + boardPublicId: String, + title: String, + appendIndex: Int, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + val payload = JSONObject() + .put("boardPublicId", boardPublicId) + .put("name", title) + .put("index", appendIndex) + request( + baseUrl = baseUrl, + path = "/api/v1/lists", + method = "POST", + apiKey = apiKey, + body = payload.toString(), + ) { code, body -> + if (code in 200..299) { + parseCreatedEntityRef(body) + ?.let { BoardsApiResult.Success(it) } + ?: BoardsApiResult.Failure("Malformed create list response.") + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun createCard( + baseUrl: String, + apiKey: String, + listPublicId: String, + title: String, + description: String?, + dueDate: String?, + tagPublicIds: List, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + val payload = JSONObject() + .put("listPublicId", listPublicId) + .put("title", title) + .put("description", description ?: "") + .put("dueDate", dueDate) + .put("index", 0) + .put("labelPublicIds", JSONArray(tagPublicIds)) + request( + baseUrl = baseUrl, + path = "/api/v1/cards", + method = "POST", + apiKey = apiKey, + body = payload.toString(), + ) { code, body -> + if (code in 200..299) { + parseCreatedEntityRef(body) + ?.let { BoardsApiResult.Success(it) } + ?: BoardsApiResult.Failure("Malformed create card response.") + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + override suspend fun moveCard( baseUrl: String, apiKey: String, @@ -667,6 +755,27 @@ class HttpKanbnApiClient : KanbnApiClient { return LabelDetail(id = id, colorHex = colorHex) } + private fun parseCreatedEntityRef(body: String): CreatedEntityRef? { + if (body.isBlank()) { + return null + } + + val root = parseJsonObject(body) ?: return null + val data = root["data"] + val entity = when (data) { + is Map<*, *> -> { + (data["card"] as? Map<*, *>) + ?: (data["list"] as? Map<*, *>) + ?: data + } + + else -> root + } + + val publicId = extractString(entity, "publicId", "public_id", "id") + return CreatedEntityRef(publicId = publicId.ifBlank { null }) + } + private fun parseLists(board: Map<*, *>): List { return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList -> val id = extractId(rawList) 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 a00ad43..0501716 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 @@ -69,6 +69,46 @@ class BoardDetailRepository( ) } + suspend fun createCard( + listId: String, + title: String, + description: String?, + dueDate: String?, + tagIds: Collection, + ): BoardsApiResult { + val normalizedListId = listId.trim() + if (normalizedListId.isBlank()) { + return BoardsApiResult.Failure("List id is required") + } + + val normalizedTitle = title.trim() + if (normalizedTitle.isBlank()) { + return BoardsApiResult.Failure("Card title is required") + } + + val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() } + val normalizedDueDate = dueDate?.trim()?.takeIf { it.isNotBlank() } + val normalizedTagIds = tagIds + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return sessionResult + } + + return apiClient.createCard( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + listPublicId = normalizedListId, + title = normalizedTitle, + description = normalizedDescription, + dueDate = normalizedDueDate, + tagPublicIds = normalizedTagIds, + ) + } + suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { val normalizedTargetListId = targetListId.trim() if (normalizedTargetListId.isBlank()) { 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 9916424..350bd37 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 @@ -207,6 +207,23 @@ class BoardDetailRepositoryTest { assertEquals("New title", apiClient.lastListTitle) } + @Test + fun createCard_callsApiWithPublicIdsAndTopIndex() = runTest { + val apiClient = FakeBoardDetailApiClient() + val repository = createRepository(apiClient = apiClient) + + val result = repository.createCard( + listId = " list-1 ", + title = " Card title ", + description = "Description", + dueDate = null, + tagIds = listOf(" tag-1 ", "", "tag-2"), + ) + + assertTrue(result is BoardsApiResult.Success<*>) + assertEquals("list-1", apiClient.lastCreateCardListPublicId) + } + @Test fun getBoardDetailValidatesBoardId() = runTest { val repository = createRepository() @@ -454,6 +471,8 @@ class BoardDetailRepositoryTest { BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) var boardDetailResult: BoardsApiResult = BoardsApiResult.Success(sampleBoardDetail()) var renameListResult: BoardsApiResult = BoardsApiResult.Success(Unit) + var createListResult: BoardsApiResult = BoardsApiResult.Success(CreatedEntityRef("list-new")) + var createCardResult: BoardsApiResult = BoardsApiResult.Success(CreatedEntityRef("card-new")) var moveOutcomes: Map> = emptyMap() var deleteOutcomes: Map> = emptyMap() var labelByIdResults: Map> = emptyMap() @@ -466,6 +485,7 @@ class BoardDetailRepositoryTest { var deletedCardIds: MutableList = mutableListOf() var lastMoveTargetListId: String? = null var getLabelByPublicIdCalls: MutableList = mutableListOf() + var lastCreateCardListPublicId: String? = null override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success @@ -497,6 +517,29 @@ class BoardDetailRepositoryTest { return renameListResult } + override suspend fun createList( + baseUrl: String, + apiKey: String, + boardPublicId: String, + title: String, + appendIndex: Int, + ): BoardsApiResult { + return createListResult + } + + override suspend fun createCard( + baseUrl: String, + apiKey: String, + listPublicId: String, + title: String, + description: String?, + dueDate: String?, + tagPublicIds: List, + ): BoardsApiResult { + lastCreateCardListPublicId = listPublicId + return createCardResult + } + override suspend fun moveCard( baseUrl: String, apiKey: String,