diff --git a/AGENTS.md b/AGENTS.md index e2a985e..7c74695 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - 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. +- 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. The repository now also exposes create-list and create-card methods that validate/normalize user input and delegate to API create endpoints using public IDs. 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/boarddetail/BoardDetailRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt index 3086335..61c20e2 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 @@ -70,15 +70,40 @@ class BoardDetailRepository( ) } + suspend fun createList(boardPublicId: String, title: String): BoardsApiResult { + val normalizedBoardPublicId = boardPublicId.trim() + if (normalizedBoardPublicId.isBlank()) { + return BoardsApiResult.Failure("Board id is required") + } + + val normalizedTitle = title.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.createList( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + boardPublicId = normalizedBoardPublicId, + title = normalizedTitle, + appendIndex = 0, + ) + } + suspend fun createCard( - listId: String, + listPublicId: String, title: String, description: String?, dueDate: LocalDate?, - tagIds: Collection, + tagPublicIds: Collection, ): BoardsApiResult { - val normalizedListId = listId.trim() - if (normalizedListId.isBlank()) { + val normalizedListPublicId = listPublicId.trim() + if (normalizedListPublicId.isBlank()) { return BoardsApiResult.Failure("List id is required") } @@ -88,7 +113,7 @@ class BoardDetailRepository( } val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() } - val normalizedTagIds = tagIds + val normalizedTagPublicIds = tagPublicIds .map { it.trim() } .filter { it.isNotBlank() } .distinct() @@ -101,11 +126,11 @@ class BoardDetailRepository( return apiClient.createCard( baseUrl = session.baseUrl, apiKey = session.apiKey, - listPublicId = normalizedListId, + listPublicId = normalizedListPublicId, title = normalizedTitle, description = normalizedDescription, dueDate = dueDate, - tagPublicIds = normalizedTagIds, + tagPublicIds = normalizedTagPublicIds, ) } 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 b1632ed..662e503 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 @@ -214,11 +214,11 @@ class BoardDetailRepositoryTest { val repository = createRepository(apiClient = apiClient) val result = repository.createCard( - listId = " list-1 ", + listPublicId = " list-1 ", title = " Card title ", description = " Description ", dueDate = LocalDate.of(2026, 3, 16), - tagIds = listOf(" tag-1 ", "", "tag-2", "tag-1", " ", " tag-2"), + tagPublicIds = listOf(" tag-1 ", "", "tag-2", "tag-1", " ", " tag-2"), ) assertTrue(result is BoardsApiResult.Success<*>) @@ -235,17 +235,59 @@ class BoardDetailRepositoryTest { val repository = createRepository(apiClient = apiClient) val result = repository.createCard( - listId = "list-1", + listPublicId = "list-1", title = "Card", description = " ", dueDate = null, - tagIds = emptyList(), + tagPublicIds = emptyList(), ) assertTrue(result is BoardsApiResult.Success<*>) assertEquals(null, apiClient.lastCreateCardDescription) } + @Test + fun createCard_delegatesWhenDueDateIsNull() = runTest { + val apiClient = FakeBoardDetailApiClient() + val repository = createRepository(apiClient = apiClient) + + val result = repository.createCard( + listPublicId = "list-1", + title = "Card", + description = "Description", + dueDate = null, + tagPublicIds = listOf("tag-1"), + ) + + assertTrue(result is BoardsApiResult.Success<*>) + assertEquals("list-1", apiClient.lastCreateCardListPublicId) + assertEquals("Card", apiClient.lastCreateCardTitle) + assertEquals(null, apiClient.lastCreateCardDueDate) + } + + @Test + fun createListRejectsBlankTitle() = runTest { + val repository = createRepository() + + val result = repository.createList(boardPublicId = "board-1", title = " ") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("List title is required", (result as BoardsApiResult.Failure).message) + } + + @Test + fun createListDelegatesToApiWithTrimmedIds() = runTest { + val apiClient = FakeBoardDetailApiClient() + val repository = createRepository(apiClient = apiClient) + + val result = repository.createList(boardPublicId = " board-1 ", title = " New List ") + + assertTrue(result is BoardsApiResult.Success<*>) + assertEquals("board-1", apiClient.lastCreateListBoardPublicId) + assertEquals("New List", apiClient.lastCreateListTitle) + assertEquals(0, apiClient.lastCreateListAppendIndex) + } + @Test fun getBoardDetailValidatesBoardId() = runTest { val repository = createRepository() @@ -507,6 +549,9 @@ class BoardDetailRepositoryTest { var deletedCardIds: MutableList = mutableListOf() var lastMoveTargetListId: String? = null var getLabelByPublicIdCalls: MutableList = mutableListOf() + var lastCreateListBoardPublicId: String? = null + var lastCreateListTitle: String? = null + var lastCreateListAppendIndex: Int? = null var lastCreateCardListPublicId: String? = null var lastCreateCardTitle: String? = null var lastCreateCardDescription: String? = null @@ -550,6 +595,9 @@ class BoardDetailRepositoryTest { title: String, appendIndex: Int, ): BoardsApiResult { + lastCreateListBoardPublicId = boardPublicId + lastCreateListTitle = title + lastCreateListAppendIndex = appendIndex return createListResult }