feat: add board detail repository create list and card operations

This commit is contained in:
2026-03-16 13:48:35 -04:00
parent 3d8b9e4491
commit 7b1c51eae0
3 changed files with 85 additions and 12 deletions

View File

@@ -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. - 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 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. - 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** **Card detail view**
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it. - The view shows the card's title in bold letters. Tapping on the card's title allows editing it.

View File

@@ -70,15 +70,40 @@ class BoardDetailRepository(
) )
} }
suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
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( suspend fun createCard(
listId: String, listPublicId: String,
title: String, title: String,
description: String?, description: String?,
dueDate: LocalDate?, dueDate: LocalDate?,
tagIds: Collection<String>, tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> { ): BoardsApiResult<CreatedEntityRef> {
val normalizedListId = listId.trim() val normalizedListPublicId = listPublicId.trim()
if (normalizedListId.isBlank()) { if (normalizedListPublicId.isBlank()) {
return BoardsApiResult.Failure("List id is required") return BoardsApiResult.Failure("List id is required")
} }
@@ -88,7 +113,7 @@ class BoardDetailRepository(
} }
val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() } val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() }
val normalizedTagIds = tagIds val normalizedTagPublicIds = tagPublicIds
.map { it.trim() } .map { it.trim() }
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
@@ -101,11 +126,11 @@ class BoardDetailRepository(
return apiClient.createCard( return apiClient.createCard(
baseUrl = session.baseUrl, baseUrl = session.baseUrl,
apiKey = session.apiKey, apiKey = session.apiKey,
listPublicId = normalizedListId, listPublicId = normalizedListPublicId,
title = normalizedTitle, title = normalizedTitle,
description = normalizedDescription, description = normalizedDescription,
dueDate = dueDate, dueDate = dueDate,
tagPublicIds = normalizedTagIds, tagPublicIds = normalizedTagPublicIds,
) )
} }

View File

@@ -214,11 +214,11 @@ class BoardDetailRepositoryTest {
val repository = createRepository(apiClient = apiClient) val repository = createRepository(apiClient = apiClient)
val result = repository.createCard( val result = repository.createCard(
listId = " list-1 ", listPublicId = " list-1 ",
title = " Card title ", title = " Card title ",
description = " Description ", description = " Description ",
dueDate = LocalDate.of(2026, 3, 16), 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<*>) assertTrue(result is BoardsApiResult.Success<*>)
@@ -235,17 +235,59 @@ class BoardDetailRepositoryTest {
val repository = createRepository(apiClient = apiClient) val repository = createRepository(apiClient = apiClient)
val result = repository.createCard( val result = repository.createCard(
listId = "list-1", listPublicId = "list-1",
title = "Card", title = "Card",
description = " ", description = " ",
dueDate = null, dueDate = null,
tagIds = emptyList(), tagPublicIds = emptyList(),
) )
assertTrue(result is BoardsApiResult.Success<*>) assertTrue(result is BoardsApiResult.Success<*>)
assertEquals(null, apiClient.lastCreateCardDescription) 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 @Test
fun getBoardDetailValidatesBoardId() = runTest { fun getBoardDetailValidatesBoardId() = runTest {
val repository = createRepository() val repository = createRepository()
@@ -507,6 +549,9 @@ class BoardDetailRepositoryTest {
var deletedCardIds: MutableList<String> = mutableListOf() var deletedCardIds: MutableList<String> = mutableListOf()
var lastMoveTargetListId: String? = null var lastMoveTargetListId: String? = null
var getLabelByPublicIdCalls: MutableList<String> = mutableListOf() var getLabelByPublicIdCalls: MutableList<String> = mutableListOf()
var lastCreateListBoardPublicId: String? = null
var lastCreateListTitle: String? = null
var lastCreateListAppendIndex: Int? = null
var lastCreateCardListPublicId: String? = null var lastCreateCardListPublicId: String? = null
var lastCreateCardTitle: String? = null var lastCreateCardTitle: String? = null
var lastCreateCardDescription: String? = null var lastCreateCardDescription: String? = null
@@ -550,6 +595,9 @@ class BoardDetailRepositoryTest {
title: String, title: String,
appendIndex: Int, appendIndex: Int,
): BoardsApiResult<CreatedEntityRef> { ): BoardsApiResult<CreatedEntityRef> {
lastCreateListBoardPublicId = boardPublicId
lastCreateListTitle = title
lastCreateListAppendIndex = appendIndex
return createListResult return createListResult
} }