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

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(
listId: String,
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagIds: Collection<String>,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
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,
)
}

View File

@@ -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<String> = mutableListOf()
var lastMoveTargetListId: String? = null
var getLabelByPublicIdCalls: MutableList<String> = 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<CreatedEntityRef> {
lastCreateListBoardPublicId = boardPublicId
lastCreateListTitle = title
lastCreateListAppendIndex = appendIndex
return createListResult
}