feat: add board detail repository create list and card operations
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user