Compare commits

..

21 Commits

Author SHA1 Message Date
81f95c1559 docs: resolve AGENTS merge after board-detail updates 2026-03-16 18:41:19 -04:00
e6f47034bc fix: align select-all behavior and board-detail docs with filters 2026-03-16 16:03:47 -04:00
dc493f5037 docs: improve status readability and record verification outcomes 2026-03-16 15:57:27 -04:00
a63799138b docs: update board detail status for create and filter features 2026-03-16 15:51:16 -04:00
28b81c96a0 chore: revert out-of-scope AGENTS update for task 6 2026-03-16 15:43:22 -04:00
3d4661cbfd test: cover board detail create and filter user flows 2026-03-16 15:38:34 -04:00
1e5979f5c4 fix: align board detail toolbar and add-card dialog scaffolding 2026-03-16 15:02:26 -04:00
3247892038 chore: revert out-of-scope AGENTS update for task 5 2026-03-16 14:54:46 -04:00
b936baf564 feat: add board detail create and filter toolbar UI 2026-03-16 14:47:31 -04:00
de7bb48fe2 test: cover created id fallback keys for create endpoints 2026-03-16 14:17:55 -04:00
c48cd1d525 chore: revert out-of-scope AGENTS update for task 4b 2026-03-16 14:13:39 -04:00
6d313fdf60 test: add create endpoint contract coverage for api client 2026-03-16 14:12:33 -04:00
6a18d6679a fix: separate local create validation from server errors in board detail viewmodel 2026-03-16 14:09:44 -04:00
5e0eff37a6 feat: add board detail create and local filter viewmodel state 2026-03-16 14:04:10 -04:00
8022647047 fix: derive createList append index from board detail 2026-03-16 13:56:45 -04:00
85659f070b test: cover repository create validation guards 2026-03-16 13:51:03 -04:00
7b1c51eae0 feat: add board detail repository create list and card operations 2026-03-16 13:48:35 -04:00
3d8b9e4491 fix: align createCard due-date contract with api client ownership 2026-03-16 13:44:06 -04:00
efe19c794f test: tighten createCard repository delegation normalization coverage 2026-03-16 13:38:11 -04:00
995a6dcae7 feat: add board detail create list and card api calls 2026-03-16 13:34:59 -04:00
80d4c40f10 feat: add board detail create entity reference model 2026-03-16 13:27:11 -04:00
21 changed files with 2643 additions and 31 deletions

View File

@@ -97,12 +97,17 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Long-pressing any of the buttons must show a tooltip with the button name.
- The view has a floating + button that shows a modal dialog that allows creating a new card using the Kan.bn API.
- The new card is added to the top of the currently shown list.
- The modal dialog has a field for the card's name. This field is mandatory
- Below the card name field there is a markdown-enabled text area for an optional card description.
- Below the card description field there is an optional date field to set the card's due date.
- Below the card's due date field there is an optional multi-value selector that allows choosing the card's tags from the tags available for the current board.
- The view has a floating + button that allows choosing between two options: "Add new list", "Add new card"
- The Add new list option shows a modal dialog that asks for a list title. The modal dialog has two buttons at the bottom for "Cancel" and "Create"
- The list is created using the Kan.bn API.
- The new list is added at the end of the lists in the current board after the current last list.
- The Add new card dialog shows a modal dialog that works as follows:
- The modal dialog has a field for the card's name. This field is mandatory
- Below the card name field there is a markdown-enabled text area for an optional card description.
- Below the card description field there is an optional date field to set the card's due date.
- Below the card's due date field there is an optional multi-value selector that allows choosing the card's tags from the tags available for the current board.
- The new card is created using the Kan.bn API.
- The new card is added to the top of the currently shown list.
- The title bar of the view has two icon-only buttons for "Filter by tag" (icon is three bars of decreasing width, widest on top) and "Search" (icon is a leaning looking glass)
- The filter by tag button opens a modal dialog that shows a multi-value selector that allows choosing from the tags available on the current board. The modal has a title that says "Filter by tag". The modal has buttons for "Cancel" and "Filter".
- The search button a modal dialog that shows a text field that has the placeholder value "Search". The modal has a title that seas "Search by title". The modal has buttons for "Cancel" and "Search".
@@ -113,7 +118,18 @@ 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, and card rendering (title/tags/due date locale formatting and expiry color).
- FAB flows are implemented for both add-list and add-card dialogs.
- Filter/search behavior is local (no server roundtrip), and active filter/search icons are highlighted.
- Cross-page card selection is implemented. In selection mode, toolbar actions are replaced (filter/search hidden; select-all/move/delete shown).
- Select-all is page-scoped, move uses a list selector dialog, and delete uses two-step confirmation.
- Back handling clears selection from both the top-bar back arrow and the system back button before navigation.
- The screen includes mutation guards while in progress and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`.
- Card move requests try these variants for Kan.bn API compatibility: `PUT /api/v1/cards/{cardPublicId}` with `listPublicId`, then 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 prefers public ids (`publicId`/`public_id`) over internal `id` values so follow-up card/list mutations target correct API identifiers.
- Label chip border colors are hydrated from Kan.bn `Get a label by public ID` (`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 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

@@ -9,10 +9,12 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
@@ -27,6 +29,8 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.color.MaterialColors
import com.google.android.material.chip.Chip
import java.text.DateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.util.ArrayDeque
import java.util.Date
import java.util.Locale
@@ -50,6 +54,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailDataSource
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
@@ -63,6 +68,7 @@ class BoardDetailFlowTest {
@Before
fun setUp() {
observedStates.clear()
MainActivity.dependencies.clear()
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
@@ -307,6 +313,251 @@ class BoardDetailFlowTest {
}
}
@Test
fun toolbarNormalMode_showsFilterAndSearchActions() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).check(matches(isDisplayed()))
awaitCondition {
var present = false
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
present = menu.findItem(R.id.actionFilterByTag) != null && menu.findItem(R.id.actionSearch) != null
}
present
}
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertNotNull(menu.findItem(R.id.actionFilterByTag))
assertNotNull(menu.findItem(R.id.actionSearch))
}
}
@Test
fun selectionMode_replacesFilterSearchWithSelectionActions() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertNotNull(menu.findItem(R.id.actionSelectAll))
assertNotNull(menu.findItem(R.id.actionMoveCards))
assertNotNull(menu.findItem(R.id.actionDeleteCards))
assertNull(menu.findItem(R.id.actionFilterByTag))
assertNull(menu.findItem(R.id.actionSearch))
}
}
@Test
fun fab_isVisible_andOpensAddChooser() {
launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).check(matches(isDisplayed()))
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText("Add new list")).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText("Add new card")).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun fabChooser_addList_createAndValidationFlow() {
launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText(R.string.add_new_list)).inRoot(isDialog()).perform(click())
onView(withText(R.string.add_new_list)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.add_list)).inRoot(isDialog()).perform(click())
onView(withText(R.string.list_title_required)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withId(R.id.addListTitleInput)).inRoot(isDialog()).perform(replaceText("Created List"))
onView(withText(R.string.add_list)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.createListCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
assertEquals(1, defaultDataSource.createListCalls)
assertEquals("board-1", defaultDataSource.lastCreateListBoardId)
assertEquals("Created List", defaultDataSource.lastCreateListTitle)
assertTrue(
observedStates.lastOrNull()
?.boardDetail
?.lists
?.any { it.title == "Created List" } == true,
)
}
@Test
fun fabChooser_addCard_createWithTagsAndClearDate() {
defaultDataSource.currentDetail = detailCreateCardDialogTagCatalog()
val scenario = launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText(R.string.add_new_card)).inRoot(isDialog()).perform(click())
onView(withId(R.id.addCardTitleInput)).inRoot(isDialog()).perform(replaceText("Created Card"))
onView(withId(R.id.addCardDescriptionInput)).inRoot(isDialog()).perform(replaceText("Body"))
onView(withText("Backend")).inRoot(isDialog()).perform(click())
onView(withId(R.id.addCardDueDateText)).inRoot(isDialog()).perform(click())
onView(withText(android.R.string.ok)).inRoot(isDialog()).perform(click())
awaitCondition {
observedStates.lastOrNull()?.addCardDueDate != null
}
scenario.onActivity { }
onView(withId(R.id.addCardClearDueDateAction)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withId(R.id.addCardClearDueDateAction)).inRoot(isDialog()).perform(click())
awaitCondition {
observedStates.lastOrNull()?.addCardDueDate == null
}
onView(withText(R.string.add_card)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.createCardCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
assertEquals(1, defaultDataSource.createCardCalls)
assertEquals("list-1", defaultDataSource.lastCreateCardListId)
assertEquals("Created Card", defaultDataSource.lastCreateCardTitle)
assertEquals("Body", defaultDataSource.lastCreateCardDescription)
assertEquals(setOf("tag-1"), defaultDataSource.lastCreateCardTagIds)
assertNull(defaultDataSource.lastCreateCardDueDate)
}
@Test
fun fabChooser_zeroLists_disablesAddCard_andShowsHelperMessage() {
defaultDataSource.currentDetail = BoardDetail(id = "board-1", title = "Board", lists = emptyList())
launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText(R.string.add_new_card)).inRoot(isDialog()).check(matches(not(isEnabled())))
onView(withText(R.string.create_a_list_first_to_add_cards)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun filters_applyTagAnyAndSearchAnd_keepAllListsVisible() {
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
val scenario = launchBoardDetail()
onView(withContentDescription("Filter by tag")).perform(click())
onView(withText("Backend")).inRoot(isDialog()).perform(click())
onView(withText("Mobile")).inRoot(isDialog()).perform(click())
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
onView(withContentDescription("Search")).perform(click())
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Duke"))
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
onView(withText("Duke Backend")).check(matches(isDisplayed()))
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(2, false)
}
onView(withText("Done")).check(matches(isDisplayed()))
onView(withText(R.string.board_detail_empty_list)).check(matches(isDisplayed()))
}
@Test
fun filterAndSearchActions_showActiveTintWhenCriteriaApplied_andResetOnClear() {
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
val scenario = launchBoardDetail()
onView(withContentDescription("Filter by tag")).perform(click())
onView(withText("Backend")).inRoot(isDialog()).perform(click())
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
onView(withContentDescription("Search")).perform(click())
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Duke"))
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
val expectedActive = MaterialColors.getColor(
activity,
com.google.android.material.R.attr.colorPrimary,
Color.BLUE,
)
assertEquals(expectedActive, menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor)
assertEquals(expectedActive, menu.findItem(R.id.actionSearch).iconTintList?.defaultColor)
}
onView(withContentDescription("Filter by tag")).perform(click())
onView(withContentDescription("Search")).perform(click())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
val expectedInactive = MaterialColors.getColor(
activity,
com.google.android.material.R.attr.colorOnSurfaceVariant,
Color.BLACK,
)
assertEquals(expectedInactive, menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor)
assertEquals(expectedInactive, menu.findItem(R.id.actionSearch).iconTintList?.defaultColor)
}
}
@Test
fun activeFilterOrSearchIconTap_clearsCriterionImmediately() {
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
val scenario = launchBoardDetail()
onView(withContentDescription("Filter by tag")).perform(click())
onView(withText("Backend")).inRoot(isDialog()).perform(click())
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
onView(withText("Duke Mobile")).check(doesNotExist())
onView(withContentDescription("Filter by tag")).perform(click())
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
onView(withContentDescription("Search")).perform(click())
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Backend"))
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
onView(withText(R.string.board_detail_empty_list)).check(matches(isDisplayed()))
onView(withContentDescription("Search")).perform(click())
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
}
@Test
fun selectionMode_hidesFilterAndSearchActions() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertNull(menu.findItem(R.id.actionFilterByTag))
assertNull(menu.findItem(R.id.actionSearch))
assertNotNull(menu.findItem(R.id.actionSelectAll))
}
}
@Test
fun backArrowAndSystemBack_clearSelectionBeforeNavigation() {
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withId(R.id.actionSelectAll)).check(matches(isDisplayed()))
onView(withContentDescription(androidx.appcompat.R.string.abc_action_bar_up_description)).perform(click())
onView(withId(R.id.actionFilterByTag)).check(matches(isDisplayed()))
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withId(R.id.actionSelectAll)).check(matches(isDisplayed()))
pressBack()
onView(withId(R.id.actionFilterByTag)).check(matches(isDisplayed()))
}
@Test
fun missingBoardIdShowsBlockingDialogAndFinishes() {
val scenario = launchBoardDetail(boardId = null)
@@ -632,9 +883,88 @@ class BoardDetailFlowTest {
var deleteGate: CompletableDeferred<Unit>? = null
var moveCalls: Int = 0
var deleteCalls: Int = 0
var createListCalls: Int = 0
var createCardCalls: Int = 0
var lastMoveCardIds: Set<String> = emptySet()
var lastDeleteCardIds: Set<String> = emptySet()
var lastMoveTargetListId: String? = null
var lastCreateListBoardId: String? = null
var lastCreateListTitle: String? = null
var lastCreateCardListId: String? = null
var lastCreateCardTitle: String? = null
var lastCreateCardDescription: String? = null
var lastCreateCardDueDate: LocalDate? = null
var lastCreateCardTagIds: Set<String> = emptySet()
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("created-list"))
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("created-card"))
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
createListCalls += 1
lastCreateListBoardId = boardPublicId
lastCreateListTitle = title
val result = createListResult
if (result is BoardsApiResult.Success) {
val newId = result.value.publicId?.trim().orEmpty().ifBlank { "created-list-${createListCalls}" }
currentDetail = currentDetail.copy(
lists = currentDetail.lists + BoardListDetail(
id = newId,
title = title,
cards = emptyList(),
),
)
}
return result
}
override suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
createCardCalls += 1
lastCreateCardListId = listPublicId
lastCreateCardTitle = title
lastCreateCardDescription = description
lastCreateCardDueDate = dueDate
lastCreateCardTagIds = tagPublicIds.toSet()
val result = createCardResult
if (result is BoardsApiResult.Success) {
val newId = result.value.publicId?.trim().orEmpty().ifBlank { "created-card-${createCardCalls}" }
val knownTagsById = currentDetail.lists
.asSequence()
.flatMap { list -> list.cards.asSequence() }
.flatMap { card -> card.tags.asSequence() }
.associateBy { tag -> tag.id }
val newTags = tagPublicIds.mapNotNull { knownTagsById[it] }
val dueAt = dueDate
?.atStartOfDay(ZoneId.systemDefault())
?.toInstant()
?.toEpochMilli()
currentDetail = currentDetail.copy(
lists = currentDetail.lists.map { list ->
if (list.id == listPublicId) {
list.copy(
cards = listOf(
BoardCardSummary(
id = newId,
title = title,
tags = newTags,
dueAtEpochMillis = dueAt,
),
) + list.cards,
)
} else {
list
}
},
)
}
return result
}
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
loadGate?.await()
@@ -962,5 +1292,74 @@ class BoardDetailFlowTest {
),
)
}
fun detailCreateCardDialogTagCatalog(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Existing",
tags = listOf(
BoardTagSummary("tag-1", "Backend", "#008080"),
BoardTagSummary("tag-2", "Mobile", "#990000"),
),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailFilterAndSearchThreeLists(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "todo-duke-backend",
title = "Duke Backend",
tags = listOf(BoardTagSummary("tag-1", "Backend", "#008080")),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(
BoardCardSummary(
id = "doing-duke-mobile",
title = "Duke Mobile",
tags = listOf(BoardTagSummary("tag-2", "Mobile", "#009900")),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-3",
title = "Done",
cards = listOf(
BoardCardSummary(
id = "done-archived",
title = "Archived",
tags = listOf(BoardTagSummary("tag-3", "Ops", "#333333")),
dueAtEpochMillis = null,
),
),
),
),
)
}
}
}

View File

@@ -4,6 +4,7 @@ import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.time.Instant
import java.time.LocalDate
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.coroutines.Dispatchers
@@ -12,6 +13,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 +64,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<CreatedEntityRef> {
return BoardsApiResult.Failure("List creation is not implemented.")
}
suspend fun createCard(
baseUrl: String,
apiKey: String,
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Failure("Card creation is not implemented.")
}
suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Card move is not implemented.")
}
@@ -269,6 +293,79 @@ class HttpKanbnApiClient : KanbnApiClient {
}
}
override suspend fun createList(
baseUrl: String,
apiKey: String,
boardPublicId: String,
title: String,
appendIndex: Int,
): BoardsApiResult<CreatedEntityRef> {
return withContext(Dispatchers.IO) {
val payload =
"{" +
"\"boardPublicId\":\"${jsonEscape(boardPublicId)}\"," +
"\"name\":\"${jsonEscape(title)}\"," +
"\"index\":$appendIndex" +
"}"
request(
baseUrl = baseUrl,
path = "/api/v1/lists",
method = "POST",
apiKey = apiKey,
body = payload,
) { 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: LocalDate?,
tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> {
return withContext(Dispatchers.IO) {
val payloadFields = mutableListOf(
"\"listPublicId\":\"${jsonEscape(listPublicId)}\"",
"\"title\":\"${jsonEscape(title)}\"",
"\"index\":0",
"\"labelPublicIds\":${jsonStringArray(tagPublicIds)}",
)
if (description != null) {
payloadFields += "\"description\":\"${jsonEscape(description)}\""
}
if (dueDate != null) {
payloadFields += "\"dueDate\":\"${dueDate}T00:00:00Z\""
}
val payload = "{${payloadFields.joinToString(",")}}"
request(
baseUrl = baseUrl,
path = "/api/v1/cards",
method = "POST",
apiKey = apiKey,
body = payload,
) { 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 +764,31 @@ 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["card"] as? Map<*, *>)
?: (root["list"] as? Map<*, *>)
?: root
}
}
val publicId = extractString(entity, "publicId", "public_id", "id")
return CreatedEntityRef(publicId = publicId.ifBlank { null })
}
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
val id = extractId(rawList)
@@ -781,6 +903,10 @@ class HttpKanbnApiClient : KanbnApiClient {
return builder.toString()
}
private fun jsonStringArray(values: List<String>): String {
return values.joinToString(prefix = "[", postfix = "]", separator = ",") { "\"${jsonEscape(it)}\"" }
}
private class MiniJsonParser(private val input: String) {
private var index = 0

View File

@@ -1,22 +1,38 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.app.DatePickerDialog
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.google.android.material.snackbar.Snackbar
import java.text.DateFormat
import java.time.ZoneId
import java.util.Date
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.MainActivity
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
@@ -42,8 +58,14 @@ class BoardDetailActivity : AppCompatActivity() {
private lateinit var fullScreenErrorContainer: View
private lateinit var fullScreenErrorText: TextView
private lateinit var retryButton: Button
private lateinit var createFab: FloatingActionButton
private var inlineTitleErrorMessage: String? = null
private var fabChooserDialog: AlertDialog? = null
private var addListDialog: AlertDialog? = null
private var addCardDialog: AlertDialog? = null
private var filterDialog: AlertDialog? = null
private var searchDialog: AlertDialog? = null
private var moveDialog: AlertDialog? = null
private var deleteSecondConfirmationDialog: AlertDialog? = null
private var dismissMoveDialogWhenMutationEnds: Boolean = false
@@ -105,6 +127,42 @@ class BoardDetailActivity : AppCompatActivity() {
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
renderToolbarMenu(menu, viewModel.uiState.value)
return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
renderToolbarMenu(menu, viewModel.uiState.value)
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.actionFilterByTag -> {
viewModel.onTagFilterIconTapped()
true
}
R.id.actionSearch -> {
viewModel.onSearchIconTapped()
true
}
R.id.actionSelectAll,
R.id.actionMoveCards,
R.id.actionDeleteCards,
-> handleSelectionAction(item)
else -> super.onOptionsItemSelected(item)
}
}
override fun onPostResume() {
super.onPostResume()
renderSelectionActions(viewModel.uiState.value)
}
private fun bindViews() {
toolbar = findViewById(R.id.boardDetailToolbar)
pager = findViewById(R.id.boardDetailPager)
@@ -113,6 +171,7 @@ class BoardDetailActivity : AppCompatActivity() {
fullScreenErrorContainer = findViewById(R.id.boardDetailFullScreenErrorContainer)
fullScreenErrorText = findViewById(R.id.boardDetailFullScreenErrorText)
retryButton = findViewById(R.id.boardDetailRetryButton)
createFab = findViewById(R.id.boardDetailCreateFab)
}
private fun setupToolbar() {
@@ -120,11 +179,14 @@ class BoardDetailActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
toolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
onBackPressed()
}
retryButton.setOnClickListener {
viewModel.retryLoad()
}
createFab.setOnClickListener {
viewModel.openFabChooser()
}
}
private fun setupPager() {
@@ -228,7 +290,7 @@ class BoardDetailActivity : AppCompatActivity() {
}
initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE
val boardLists = state.boardDetail?.lists.orEmpty()
val boardLists = state.filteredBoardDetail?.lists.orEmpty()
val applyPagerState = {
pagerAdapter.submit(
lists = boardLists,
@@ -260,7 +322,10 @@ class BoardDetailActivity : AppCompatActivity() {
}
renderSelectionActions(state)
invalidateOptionsMenu()
renderOpenDialogs(state)
createFab.isEnabled = !state.isMutating
createFab.visibility = if (state.selectedCardIds.isEmpty()) View.VISIBLE else View.GONE
}
private fun showBlockingStartupErrorAndFinish(message: String) {
@@ -275,6 +340,75 @@ class BoardDetailActivity : AppCompatActivity() {
}
private fun renderOpenDialogs(state: BoardDetailUiState) {
if (state.isFabChooserOpen && fabChooserDialog == null) {
showFabChooserDialog(state)
} else if (!state.isFabChooserOpen) {
fabChooserDialog?.dismiss()
fabChooserDialog = null
}
if (state.isAddListDialogOpen && addListDialog == null) {
showAddListDialog(state)
}
addListDialog?.let { dialog ->
val titleLayout = dialog.findViewById<TextInputLayout>(R.id.addListTitleLayout)
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addListTitleInput)
titleLayout?.error = state.addListTitleError
titleInput?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (!state.isAddListDialogOpen) {
dialog.dismiss()
addListDialog = null
}
}
if (state.isAddCardDialogOpen && addCardDialog == null) {
showAddCardDialog(state)
}
addCardDialog?.let { dialog ->
val titleLayout = dialog.findViewById<TextInputLayout>(R.id.addCardTitleLayout)
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addCardTitleInput)
val descriptionInput = dialog.findViewById<TextInputEditText>(R.id.addCardDescriptionInput)
val dueDateText = dialog.findViewById<TextView>(R.id.addCardDueDateText)
val clearDueDateAction = dialog.findViewById<TextView>(R.id.addCardClearDueDateAction)
titleLayout?.error = state.addCardTitleError
titleInput?.isEnabled = !state.isMutating
descriptionInput?.isEnabled = !state.isMutating
dueDateText?.text = formatDueDateForDialog(state.addCardDueDate)
dueDateText?.isEnabled = !state.isMutating
clearDueDateAction?.visibility = if (state.addCardDueDate != null) View.VISIBLE else View.GONE
clearDueDateAction?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (!state.isAddCardDialogOpen) {
dialog.dismiss()
addCardDialog = null
}
}
if (state.isFilterDialogOpen && filterDialog == null) {
showFilterDialog(state)
}
if (!state.isFilterDialogOpen) {
filterDialog?.dismiss()
filterDialog = null
}
if (state.isSearchDialogOpen && searchDialog == null) {
showSearchDialog(state)
}
searchDialog?.let { dialog ->
val input = dialog.findViewById<TextInputEditText>(R.id.searchTitleInput)
input?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (!state.isSearchDialogOpen) {
dialog.dismiss()
searchDialog = null
}
}
val activeMoveDialog = moveDialog
if (activeMoveDialog != null) {
val lists = state.boardDetail?.lists.orEmpty()
@@ -322,18 +456,38 @@ class BoardDetailActivity : AppCompatActivity() {
}
private fun renderSelectionActions(state: BoardDetailUiState) {
val inSelection = state.selectedCardIds.isNotEmpty()
toolbar.menu.clear()
if (!inSelection) {
// Kept for compatibility with existing call sites; menu rendering is delegated to
// framework options menu callbacks.
}
private fun renderToolbarMenu(menu: Menu, state: BoardDetailUiState) {
menu.clear()
if (state.selectedCardIds.isNotEmpty()) {
menu.add(Menu.NONE, R.id.actionSelectAll, Menu.NONE, getString(R.string.select_all)).apply {
setIcon(R.drawable.ic_select_all_grid_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
tooltipText = getString(R.string.select_all)
}
menu.add(Menu.NONE, R.id.actionMoveCards, Menu.NONE, getString(R.string.move_cards)).apply {
setIcon(R.drawable.ic_move_cards_horizontal_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
tooltipText = getString(R.string.move_cards)
}
menu.add(Menu.NONE, R.id.actionDeleteCards, Menu.NONE, getString(R.string.delete_cards)).apply {
setIcon(R.drawable.ic_delete_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
tooltipText = getString(R.string.delete_cards)
}
return
}
toolbar.inflateMenu(R.menu.menu_board_detail_selection)
toolbar.menu.findItem(R.id.actionSelectAll)?.tooltipText = getString(R.string.select_all)
toolbar.menu.findItem(R.id.actionMoveCards)?.tooltipText = getString(R.string.move_cards)
toolbar.menu.findItem(R.id.actionDeleteCards)?.tooltipText = getString(R.string.delete_cards)
toolbar.setOnMenuItemClickListener { item ->
handleSelectionAction(item)
}
menuInflater.inflate(R.menu.menu_board_detail_main, menu)
val filterItem = menu.findItem(R.id.actionFilterByTag)
val searchItem = menu.findItem(R.id.actionSearch)
filterItem?.tooltipText = getString(R.string.filter_by_tag)
searchItem?.tooltipText = getString(R.string.search)
tintMainMenuIcon(filterItem, state.activeTagFilterIds.isNotEmpty())
tintMainMenuIcon(searchItem, state.activeTitleQuery.isNotBlank())
}
private fun handleSelectionAction(item: MenuItem): Boolean {
@@ -357,6 +511,241 @@ class BoardDetailActivity : AppCompatActivity() {
}
}
private fun tintMainMenuIcon(item: MenuItem?, isActive: Boolean) {
val drawableRes = when (item?.itemId) {
R.id.actionFilterByTag -> R.drawable.ic_filter_list_24
R.id.actionSearch -> R.drawable.ic_search_24
else -> null
} ?: return
val icon = AppCompatResources.getDrawable(this, drawableRes)?.mutate() ?: return
val wrapped = DrawableCompat.wrap(icon)
val tintColor = if (isActive) {
MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, ContextCompat.getColor(this, android.R.color.holo_blue_light))
} else {
MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant, ContextCompat.getColor(this, android.R.color.black))
}
DrawableCompat.setTint(wrapped, tintColor)
item?.icon = wrapped
item?.iconTintList = android.content.res.ColorStateList.valueOf(tintColor)
}
private fun showFabChooserDialog(state: BoardDetailUiState) {
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(48, 24, 48, 24)
}
val addListButton = MaterialButton(this).apply {
text = getString(R.string.add_new_list)
setOnClickListener {
viewModel.openAddListDialog()
}
}
val addCardButton = MaterialButton(this).apply {
text = getString(R.string.add_new_card)
isEnabled = state.canAddCard
setOnClickListener {
viewModel.openAddCardDialog()
}
}
val helperText = TextView(this).apply {
text = getString(R.string.create_a_list_first_to_add_cards)
visibility = if (state.canAddCard) View.GONE else View.VISIBLE
setPadding(8, 4, 8, 0)
}
root.addView(addListButton)
root.addView(addCardButton)
root.addView(helperText)
val dialog = MaterialAlertDialogBuilder(this)
.setView(root)
.setOnDismissListener {
if (fabChooserDialog != null) {
viewModel.closeFabChooser()
}
fabChooserDialog = null
}
.create()
fabChooserDialog = dialog
dialog.show()
}
private fun showAddListDialog(state: BoardDetailUiState) {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_list, null)
val titleLayout: TextInputLayout = dialogView.findViewById(R.id.addListTitleLayout)
val titleInput: TextInputEditText = dialogView.findViewById(R.id.addListTitleInput)
titleInput.setText(state.addListTitleDraft)
titleInput.doAfterTextChanged { viewModel.updateAddListTitle(it?.toString().orEmpty()) }
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.add_new_list)
.setView(dialogView)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.add_list, null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
titleLayout.error = null
viewModel.createList()
}
}
dialog.setOnDismissListener {
if (addListDialog != null && viewModel.uiState.value.isAddListDialogOpen) {
viewModel.cancelAddListDialog()
}
addListDialog = null
}
addListDialog = dialog
dialog.show()
}
private fun showAddCardDialog(state: BoardDetailUiState) {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_card, null)
val titleInput: TextInputEditText = dialogView.findViewById(R.id.addCardTitleInput)
val descriptionInput: TextInputEditText = dialogView.findViewById(R.id.addCardDescriptionInput)
val dueDateText: TextView = dialogView.findViewById(R.id.addCardDueDateText)
val clearDueDateAction: TextView = dialogView.findViewById(R.id.addCardClearDueDateAction)
val tagsPlaceholderText: TextView = dialogView.findViewById(R.id.addCardTagsPlaceholderText)
val tagsContainer: LinearLayout = dialogView.findViewById(R.id.addCardTagsContainer)
val tags = state.boardDetail
?.lists
.orEmpty()
.flatMap { it.cards }
.flatMap { it.tags }
.distinctBy { it.id }
.sortedBy { it.name.lowercase() }
titleInput.setText(state.addCardTitleDraft)
descriptionInput.setText(state.addCardDescriptionDraft)
titleInput.doAfterTextChanged { viewModel.updateAddCardTitle(it?.toString().orEmpty()) }
descriptionInput.doAfterTextChanged { viewModel.updateAddCardDescription(it?.toString().orEmpty()) }
dueDateText.text = formatDueDateForDialog(state.addCardDueDate)
clearDueDateAction.visibility = if (state.addCardDueDate != null) View.VISIBLE else View.GONE
dueDateText.setOnClickListener { openAddCardDatePicker(state.addCardDueDate) }
clearDueDateAction.setOnClickListener { viewModel.clearDueDate() }
tagsPlaceholderText.visibility = if (tags.isEmpty()) View.VISIBLE else View.GONE
tags.forEach { tag ->
val checkBox = CheckBox(this).apply {
text = tag.name
isChecked = state.addCardSelectedTagIds.contains(tag.id)
setOnCheckedChangeListener { _, _ ->
viewModel.toggleAddCardTag(tag.id)
}
}
tagsContainer.addView(checkBox)
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.add_new_card)
.setView(dialogView)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.add_card, null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
viewModel.createCard()
}
}
dialog.setOnDismissListener {
if (addCardDialog != null && viewModel.uiState.value.isAddCardDialogOpen) {
viewModel.cancelAddCardDialog()
}
addCardDialog = null
}
addCardDialog = dialog
dialog.show()
}
private fun openAddCardDatePicker(currentDate: java.time.LocalDate?) {
val seed = currentDate ?: java.time.LocalDate.now()
DatePickerDialog(
this,
{ _, year, month, dayOfMonth ->
viewModel.setDueDate(java.time.LocalDate.of(year, month + 1, dayOfMonth))
},
seed.year,
seed.monthValue - 1,
seed.dayOfMonth,
).show()
}
private fun formatDueDateForDialog(dueDate: java.time.LocalDate?): String {
if (dueDate == null) {
return getString(R.string.due_date)
}
val epochMillis = dueDate
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
return DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(epochMillis))
}
private fun showFilterDialog(state: BoardDetailUiState) {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_filter_tags, null)
val container: LinearLayout = dialogView.findViewById(R.id.filterTagsContainer)
val tags = state.boardDetail
?.lists
.orEmpty()
.flatMap { it.cards }
.flatMap { it.tags }
.distinctBy { it.id }
.sortedBy { it.name.lowercase() }
tags.forEach { tag ->
val checkBox = CheckBox(this).apply {
text = tag.name
isChecked = state.pendingTagFilterIds.contains(tag.id)
setOnCheckedChangeListener { _, _ ->
val selected = mutableSetOf<String>()
for (i in 0 until container.childCount) {
val child = container.getChildAt(i)
if (child is CheckBox && child.isChecked) {
selected.add(tags[i].id)
}
}
viewModel.updatePendingTagFilterIds(selected)
}
}
container.addView(checkBox)
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.filter_by_tag)
.setView(dialogView)
.setNegativeButton(R.string.cancel) { _, _ -> viewModel.cancelFilterDialog() }
.setPositiveButton(R.string.filter) { _, _ -> viewModel.applyFilterDialog() }
.create()
dialog.setOnDismissListener {
if (filterDialog != null && viewModel.uiState.value.isFilterDialogOpen) {
viewModel.cancelFilterDialog()
}
filterDialog = null
}
filterDialog = dialog
dialog.show()
}
private fun showSearchDialog(state: BoardDetailUiState) {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_search_title, null)
val input: TextInputEditText = dialogView.findViewById(R.id.searchTitleInput)
input.setText(state.pendingTitleQuery)
input.doAfterTextChanged { viewModel.updatePendingTitleQuery(it?.toString().orEmpty()) }
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.search_by_title)
.setView(dialogView)
.setNegativeButton(R.string.cancel) { _, _ -> viewModel.cancelSearchDialog() }
.setPositiveButton(R.string.search) { _, _ -> viewModel.applySearchDialog() }
.create()
dialog.setOnDismissListener {
if (searchDialog != null && viewModel.uiState.value.isSearchDialogOpen) {
viewModel.cancelSearchDialog()
}
searchDialog = null
}
searchDialog = dialog
dialog.show()
}
private fun showMoveCardsDialog() {
val lists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
if (lists.isEmpty()) {

View File

@@ -25,6 +25,10 @@ data class BoardTagSummary(
val colorHex: String,
)
data class CreatedEntityRef(
val publicId: String?,
)
sealed interface CardBatchMutationResult {
data object Success : CardBatchMutationResult
data class PartialSuccess(

View File

@@ -3,6 +3,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDate
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
@@ -69,6 +70,81 @@ 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
}
val appendIndex = when (
val boardDetailResult = apiClient.getBoardDetail(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
boardId = normalizedBoardPublicId,
)
) {
is BoardsApiResult.Success -> boardDetailResult.value.lists.size
is BoardsApiResult.Failure -> return BoardsApiResult.Failure(boardDetailResult.message)
}
return apiClient.createList(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
boardPublicId = normalizedBoardPublicId,
title = normalizedTitle,
appendIndex = appendIndex,
)
}
suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
val normalizedListPublicId = listPublicId.trim()
if (normalizedListPublicId.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 normalizedTagPublicIds = tagPublicIds
.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 = normalizedListPublicId,
title = normalizedTitle,
description = normalizedDescription,
dueDate = dueDate,
tagPublicIds = normalizedTagPublicIds,
)
}
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
val normalizedTargetListId = targetListId.trim()
if (normalizedTargetListId.isBlank()) {

View File

@@ -3,6 +3,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
private const val ADD_CARD_DISABLED_MESSAGE = "Create a list first to add cards."
data class BoardDetailUiState(
val isInitialLoading: Boolean = false,
val isRefreshing: Boolean = false,
@@ -23,7 +26,35 @@ data class BoardDetailUiState(
val selectedCardIds: Set<String> = emptySet(),
val editingListId: String? = null,
val editingListTitle: String = "",
)
val pendingTagFilterIds: Set<String> = emptySet(),
val pendingTitleQuery: String = "",
val activeTagFilterIds: Set<String> = emptySet(),
val activeTitleQuery: String = "",
val isFabChooserOpen: Boolean = false,
val isAddListDialogOpen: Boolean = false,
val isAddCardDialogOpen: Boolean = false,
val isFilterDialogOpen: Boolean = false,
val isSearchDialogOpen: Boolean = false,
val addListTitleDraft: String = "",
val addListTitleError: String? = null,
val addCardTitleDraft: String = "",
val addCardTitleError: String? = null,
val addCardDescriptionDraft: String = "",
val addCardDueDate: LocalDate? = null,
val addCardSelectedTagIds: Set<String> = emptySet(),
) {
val canAddCard: Boolean
get() = boardDetail?.lists?.isNotEmpty() == true
val addCardDisabledMessage: String?
get() = if (canAddCard) null else ADD_CARD_DISABLED_MESSAGE
val filteredBoardDetail: BoardDetail?
get() = boardDetail?.withFilteredCards(
tagFilterIds = activeTagFilterIds,
titleQuery = activeTitleQuery,
)
}
sealed interface BoardDetailUiEvent {
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
@@ -33,6 +64,15 @@ sealed interface BoardDetailUiEvent {
interface BoardDetailDataSource {
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef>
suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef>
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
@@ -45,6 +85,26 @@ internal class BoardDetailRepositoryDataSource(
return repository.getBoardDetail(boardId)
}
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
return repository.createList(boardPublicId, title)
}
override suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
return repository.createCard(
listPublicId = listPublicId,
title = title,
description = description,
dueDate = dueDate,
tagPublicIds = tagPublicIds,
)
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
return repository.moveCards(cardIds, targetListId)
}
@@ -88,7 +148,7 @@ class BoardDetailViewModel(
fun selectAllOnCurrentPage() {
val current = _uiState.value
val pageCards = current.boardDetail
val pageCards = current.filteredBoardDetail
?.lists
?.getOrNull(current.currentPageIndex)
?.cards
@@ -129,6 +189,298 @@ class BoardDetailViewModel(
return true
}
fun openFabChooser() {
_uiState.update { it.copy(isFabChooserOpen = true) }
}
fun closeFabChooser() {
_uiState.update { it.copy(isFabChooserOpen = false) }
}
fun openAddListDialog() {
_uiState.update {
it.copy(
isFabChooserOpen = false,
isAddListDialogOpen = true,
addListTitleError = null,
)
}
}
fun updateAddListTitle(title: String) {
_uiState.update {
it.copy(
addListTitleDraft = title,
addListTitleError = null,
)
}
}
fun cancelAddListDialog() {
_uiState.update {
it.copy(
isAddListDialogOpen = false,
addListTitleDraft = "",
addListTitleError = null,
)
}
}
fun createList() {
val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val detail = snapshot.boardDetail ?: return
val title = snapshot.addListTitleDraft.trim()
if (title.isBlank()) {
setAddListLocalValidationError("List title is required")
return
}
val expectedIndex = detail.lists.size
viewModelScope.launch {
beginCreateListMutation()
when (val result = repository.createList(detail.id, title)) {
is BoardsApiResult.Success -> {
closeAddListDialogAndResetDrafts()
when (val reload = reloadDetailAndReconcile()) {
is BoardsApiResult.Success -> {
endMutation()
val warning = verifyCreatedList(result.value.publicId, expectedIndex, reload.value)
if (warning != null) {
_events.emit(BoardDetailUiEvent.ShowWarning(warning))
}
}
is BoardsApiResult.Failure -> {
endMutation()
emitRefreshFailureWarning()
}
}
}
is BoardsApiResult.Failure -> {
endMutation()
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
fun openAddCardDialog() {
val snapshot = _uiState.value
if (!snapshot.canAddCard) {
_uiState.update { it.copy(isFabChooserOpen = false, isAddCardDialogOpen = false) }
return
}
_uiState.update {
it.copy(
isFabChooserOpen = false,
isAddCardDialogOpen = true,
addCardTitleError = null,
)
}
}
fun updateAddCardTitle(title: String) {
_uiState.update {
it.copy(
addCardTitleDraft = title,
addCardTitleError = null,
)
}
}
fun updateAddCardDescription(description: String) {
_uiState.update { it.copy(addCardDescriptionDraft = description) }
}
fun setDueDate(dueDate: LocalDate) {
_uiState.update { it.copy(addCardDueDate = dueDate) }
}
fun clearDueDate() {
_uiState.update { it.copy(addCardDueDate = null) }
}
fun toggleAddCardTag(tagId: String) {
val normalizedTagId = tagId.trim()
if (normalizedTagId.isBlank()) {
return
}
_uiState.update {
val next = it.addCardSelectedTagIds.toMutableSet()
if (!next.add(normalizedTagId)) {
next.remove(normalizedTagId)
}
it.copy(addCardSelectedTagIds = next)
}
}
fun cancelAddCardDialog() {
_uiState.update { it.resetAddCardDrafts().copy(isAddCardDialogOpen = false) }
}
fun createCard() {
val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val detail = snapshot.boardDetail ?: return
val currentList = detail.lists.getOrNull(snapshot.currentPageIndex) ?: return
val title = snapshot.addCardTitleDraft.trim()
if (title.isBlank()) {
setAddCardLocalValidationError("Card title is required")
return
}
viewModelScope.launch {
beginCreateCardMutation()
when (
val result = repository.createCard(
listPublicId = currentList.id,
title = title,
description = snapshot.addCardDescriptionDraft,
dueDate = snapshot.addCardDueDate,
tagPublicIds = snapshot.addCardSelectedTagIds,
)
) {
is BoardsApiResult.Success -> {
closeAddCardDialogAndResetDrafts()
when (val reload = reloadDetailAndReconcile()) {
is BoardsApiResult.Success -> {
endMutation()
val warning = verifyCreatedCard(
createdCardId = result.value.publicId,
targetListId = currentList.id,
detail = reload.value,
)
if (warning != null) {
_events.emit(BoardDetailUiEvent.ShowWarning(warning))
}
}
is BoardsApiResult.Failure -> {
endMutation()
emitRefreshFailureWarning()
}
}
}
is BoardsApiResult.Failure -> {
endMutation()
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
fun openFilterDialog() {
_uiState.update {
it.copy(
isFilterDialogOpen = true,
pendingTagFilterIds = it.activeTagFilterIds,
)
}
}
fun updatePendingTagFilterIds(tagIds: Set<String>) {
_uiState.update {
it.copy(
pendingTagFilterIds = tagIds
.map { id -> id.trim() }
.filter { id -> id.isNotBlank() }
.toSet(),
)
}
}
fun applyFilterDialog() {
_uiState.update {
it.copy(
activeTagFilterIds = it.pendingTagFilterIds,
isFilterDialogOpen = false,
)
}
}
fun cancelFilterDialog() {
_uiState.update {
it.copy(
pendingTagFilterIds = it.activeTagFilterIds,
isFilterDialogOpen = false,
)
}
}
fun onTagFilterIconTapped() {
val snapshot = _uiState.value
if (snapshot.activeTagFilterIds.isNotEmpty()) {
_uiState.update {
it.copy(
activeTagFilterIds = emptySet(),
pendingTagFilterIds = emptySet(),
isFilterDialogOpen = false,
)
}
return
}
openFilterDialog()
}
fun openSearchDialog() {
_uiState.update {
it.copy(
isSearchDialogOpen = true,
pendingTitleQuery = it.activeTitleQuery,
)
}
}
fun updatePendingTitleQuery(query: String) {
_uiState.update { it.copy(pendingTitleQuery = query) }
}
fun applySearchDialog() {
_uiState.update {
val trimmed = it.pendingTitleQuery.trim()
it.copy(
activeTitleQuery = trimmed,
pendingTitleQuery = trimmed,
isSearchDialogOpen = false,
)
}
}
fun cancelSearchDialog() {
_uiState.update {
it.copy(
pendingTitleQuery = it.activeTitleQuery,
isSearchDialogOpen = false,
)
}
}
fun onSearchIconTapped() {
val snapshot = _uiState.value
if (snapshot.activeTitleQuery.isNotBlank()) {
_uiState.update {
it.copy(
activeTitleQuery = "",
pendingTitleQuery = "",
isSearchDialogOpen = false,
)
}
return
}
openSearchDialog()
}
fun moveSelectedCards(targetListId: String) {
val snapshot = _uiState.value
if (snapshot.isMutating) {
@@ -191,9 +543,9 @@ class BoardDetailViewModel(
when (val result = repository.renameList(editingListId, trimmedTitle)) {
is BoardsApiResult.Success -> {
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
val reloadFailureMessage = tryReloadDetailAndReconcile()
val reloadResult = reloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) }
if (reloadFailureMessage != null) {
if (reloadResult is BoardsApiResult.Failure) {
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
@@ -276,9 +628,9 @@ class BoardDetailViewModel(
when (val result = mutation(selectedIds)) {
is CardBatchMutationResult.Success -> {
_uiState.update { it.copy(selectedCardIds = emptySet()) }
val reloadFailureMessage = tryReloadDetailAndReconcile()
val reloadResult = reloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) }
if (reloadFailureMessage != null) {
if (reloadResult is BoardsApiResult.Failure) {
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
@@ -288,8 +640,8 @@ class BoardDetailViewModel(
}
is CardBatchMutationResult.PartialSuccess -> {
val reloadFailureMessage = tryReloadDetailAndReconcile()
if (reloadFailureMessage == null) {
val reloadResult = reloadDetailAndReconcile()
if (reloadResult is BoardsApiResult.Success) {
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
_uiState.update {
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
@@ -325,14 +677,14 @@ class BoardDetailViewModel(
}
}
private suspend fun tryReloadDetailAndReconcile(): String? {
private suspend fun reloadDetailAndReconcile(): BoardsApiResult<BoardDetail> {
return when (val result = repository.getBoardDetail(boardId)) {
is BoardsApiResult.Success -> {
_uiState.update { reconcileWithNewDetail(it, result.value) }
null
result
}
is BoardsApiResult.Failure -> result.message
is BoardsApiResult.Failure -> result
}
}
@@ -358,6 +710,8 @@ class BoardDetailViewModel(
selectedCardIds = prunedSelection,
editingListId = if (hasEditedList) current.editingListId else null,
editingListTitle = if (hasEditedList) current.editingListTitle else "",
pendingTagFilterIds = current.pendingTagFilterIds.intersect(availableTagIds(detail)),
activeTagFilterIds = current.activeTagFilterIds.intersect(availableTagIds(detail)),
)
}
@@ -377,6 +731,96 @@ class BoardDetailViewModel(
.toSet()
}
private fun availableTagIds(detail: BoardDetail): Set<String> {
return detail.lists
.asSequence()
.flatMap { list -> list.cards.asSequence() }
.flatMap { card -> card.tags.asSequence() }
.map { tag -> tag.id }
.toSet()
}
private fun verifyCreatedList(createdListId: String?, expectedIndex: Int, detail: BoardDetail): String? {
val normalizedId = createdListId?.trim().orEmpty()
if (normalizedId.isBlank()) {
return CREATE_NON_DETERMINISTIC_WARNING
}
val actualIndex = detail.lists.indexOfFirst { list -> list.id == normalizedId }
if (actualIndex < 0) {
return CREATE_NON_DETERMINISTIC_WARNING
}
return if (actualIndex != expectedIndex) CREATE_LIST_PLACEMENT_WARNING else null
}
private fun verifyCreatedCard(createdCardId: String?, targetListId: String, detail: BoardDetail): String? {
val normalizedId = createdCardId?.trim().orEmpty()
if (normalizedId.isBlank()) {
return CREATE_NON_DETERMINISTIC_WARNING
}
val list = detail.lists.firstOrNull { it.id == targetListId }
?: return CREATE_NON_DETERMINISTIC_WARNING
val actualIndex = list.cards.indexOfFirst { card -> card.id == normalizedId }
if (actualIndex < 0) {
return CREATE_NON_DETERMINISTIC_WARNING
}
return if (actualIndex != 0) CREATE_CARD_PLACEMENT_WARNING else null
}
private fun setAddListLocalValidationError(message: String) {
_uiState.update { it.copy(addListTitleError = message) }
}
private fun setAddCardLocalValidationError(message: String) {
_uiState.update { it.copy(addCardTitleError = message) }
}
private fun beginCreateListMutation() {
_uiState.update {
it.copy(
isMutating = true,
addListTitleError = null,
)
}
}
private fun beginCreateCardMutation() {
_uiState.update {
it.copy(
isMutating = true,
addCardTitleError = null,
)
}
}
private fun closeAddListDialogAndResetDrafts() {
_uiState.update { it.resetAddListDrafts().copy(isAddListDialogOpen = false) }
}
private fun closeAddCardDialogAndResetDrafts() {
_uiState.update { it.resetAddCardDrafts().copy(isAddCardDialogOpen = false) }
}
private fun endMutation() {
_uiState.update { it.copy(isMutating = false) }
}
private suspend fun emitRefreshFailureWarning() {
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
),
)
}
private companion object {
const val CREATE_LIST_PLACEMENT_WARNING =
"List created, but server ordering differed. Pull to refresh if needed."
const val CREATE_CARD_PLACEMENT_WARNING =
"Card created, but server ordering differed. Pull to refresh if needed."
const val CREATE_NON_DETERMINISTIC_WARNING =
"Created item could not be deterministically verified. Pull to refresh if needed."
}
class Factory(
private val boardId: String,
private val repository: BoardDetailRepository,
@@ -393,3 +837,47 @@ class BoardDetailViewModel(
}
}
}
private fun BoardDetailUiState.resetAddCardDrafts(): BoardDetailUiState {
return copy(
addCardTitleDraft = "",
addCardTitleError = null,
addCardDescriptionDraft = "",
addCardDueDate = null,
addCardSelectedTagIds = emptySet(),
)
}
private fun BoardDetailUiState.resetAddListDrafts(): BoardDetailUiState {
return copy(
addListTitleDraft = "",
addListTitleError = null,
)
}
private fun BoardDetail.withFilteredCards(tagFilterIds: Set<String>, titleQuery: String): BoardDetail {
val normalizedTitleQuery = titleQuery.trim()
if (tagFilterIds.isEmpty() && normalizedTitleQuery.isEmpty()) {
return this
}
return copy(
lists = lists.map { list ->
list.copy(
cards = list.cards.filter { card ->
val matchesTags = if (tagFilterIds.isEmpty()) {
true
} else {
card.tags.any { tag -> tag.id in tagFilterIds }
}
val matchesTitle = if (normalizedTitleQuery.isEmpty()) {
true
} else {
card.title.contains(normalizedTitleQuery, ignoreCase = true)
}
matchesTags && matchesTitle
},
)
},
)
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M4,7h16v2H4zM7,11h10v2H7zM10,15h4v2h-4z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15.5,14h-0.79l-0.28,-0.27a6,6 0,1 0,-1.41,1.41l0.27,0.28v0.79L20,21.5L21.5,20zM10,14a4,4 0,1 1,0 -8a4,4 0,0 1,0 8z" />
</vector>

View File

@@ -68,4 +68,13 @@
android:text="@string/retry" />
</LinearLayout>
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/boardDetailCreateFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/board_detail_add"
app:srcCompat="@android:drawable/ic_input_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/addCardTitleLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/card_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/addCardTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapSentences|text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/addCardDescriptionInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="3" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/addCardDueDateRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/addCardDueDateText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/due_date"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<TextView
android:id="@+id/addCardClearDueDateAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:text="@string/clear_due_date"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
</LinearLayout>
<LinearLayout
android:id="@+id/addCardTagSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/addCardTagsLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/add_card_tags"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall" />
<TextView
android:id="@+id/addCardTagsPlaceholderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/add_card_tags_placeholder"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<LinearLayout
android:id="@+id/addCardTagsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/addListTitleLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/list_title_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/addListTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapSentences|text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/filterTagsPlaceholderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/filter_tags_placeholder"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<LinearLayout
android:id="@+id/filterTagsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/searchTitleLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/searchTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/actionFilterByTag"
android:icon="@drawable/ic_filter_list_24"
android:title="@string/filter_by_tag"
app:showAsAction="always" />
<item
android:id="@+id/actionSearch"
android:icon="@drawable/ic_search_24"
android:title="@string/search"
app:showAsAction="always" />
</menu>

View File

@@ -50,4 +50,22 @@
<string name="card_detail_placeholder_subtitle">Card detail view is coming soon.</string>
<string name="board_detail_unable_to_open_board">Unable to open board.</string>
<string name="board_detail_session_expired">Session expired. Please sign in again.</string>
<string name="board_detail_add">Add</string>
<string name="filter_by_tag">Filter by tag</string>
<string name="search">Search</string>
<string name="search_by_title">Search by title</string>
<string name="filter">Filter</string>
<string name="add_new_list">Add new list</string>
<string name="add_new_card">Add new card</string>
<string name="add_list">Add list</string>
<string name="add_card">Add card</string>
<string name="create_a_list_first_to_add_cards">Create a list first to add cards.</string>
<string name="card_title">Card title</string>
<string name="card_title_required">Card title is required</string>
<string name="description">Description</string>
<string name="due_date">Due date</string>
<string name="clear_due_date">Clear date</string>
<string name="add_card_tags">Tags</string>
<string name="add_card_tags_placeholder">Select one or more tags.</string>
<string name="filter_tags_placeholder">Select tags to include.</string>
</resources>

View File

@@ -6,6 +6,7 @@ import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.time.Instant
import java.time.LocalDate
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@@ -17,10 +18,160 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
class HttpKanbnApiClientBoardDetailParsingTest {
@Test
fun createList_sendsAppendIndexPayload_andParsesPublicIdFallbacks() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/lists",
method = "POST",
status = 200,
responseBody = """{"list":{"public_id":"list-new"}}""",
)
val result = HttpKanbnApiClient().createList(
baseUrl = server.baseUrl,
apiKey = "api",
boardPublicId = "board-1",
title = "Sprint",
appendIndex = 7,
)
assertTrue(result is BoardsApiResult.Success<*>)
val createdRef = (result as BoardsApiResult.Success<*>).value as CreatedEntityRef
assertEquals("list-new", createdRef.publicId)
val request = server.findRequest("POST", "/api/v1/lists")
assertNotNull(request)
assertEquals("{\"boardPublicId\":\"board-1\",\"name\":\"Sprint\",\"index\":7}", request?.body)
}
}
@Test
fun createList_parsesCreatedRefPublicId_fromPublicIdPublic_idAndIdKeys() = runTest {
TestServer().use { server ->
server.registerSequence(
path = "/api/v1/lists",
method = "POST",
responses = listOf(
200 to """{"list":{"publicId":"list-from-publicId"}}""",
200 to """{"list":{"public_id":"list-from-public_id"}}""",
200 to """{"list":{"id":"list-from-id"}}""",
),
)
val client = HttpKanbnApiClient()
val first = client.createList(server.baseUrl, "api", "board-1", "List A", 0)
val second = client.createList(server.baseUrl, "api", "board-1", "List B", 1)
val third = client.createList(server.baseUrl, "api", "board-1", "List C", 2)
assertEquals("list-from-publicId", (first as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
assertEquals("list-from-public_id", (second as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
assertEquals("list-from-id", (third as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
}
}
@Test
fun createCard_serializesLocalDateAsUtcMidnight() = runTest {
TestServer().use { server ->
server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""")
val result = HttpKanbnApiClient().createCard(
baseUrl = server.baseUrl,
apiKey = "api",
listPublicId = "list-1",
title = "Card",
description = "Description",
dueDate = LocalDate.of(2026, 3, 16),
tagPublicIds = listOf("tag-1"),
)
assertTrue(result is BoardsApiResult.Success<*>)
val request = server.findRequest("POST", "/api/v1/cards")
assertNotNull(request)
assertTrue(request?.body?.contains("\"dueDate\":\"2026-03-16T00:00:00Z\"") == true)
assertTrue(request?.body?.contains("\"description\":\"Description\"") == true)
}
}
@Test
fun createCard_sendsTopIndex_andOmittedDueDateWhenNull() = runTest {
TestServer().use { server ->
server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""")
val result = HttpKanbnApiClient().createCard(
baseUrl = server.baseUrl,
apiKey = "api",
listPublicId = "list-1",
title = "Card",
description = null,
dueDate = null,
tagPublicIds = emptyList(),
)
assertTrue(result is BoardsApiResult.Success<*>)
val request = server.findRequest("POST", "/api/v1/cards")
assertNotNull(request)
assertTrue(request?.body?.contains("\"index\":0") == true)
assertTrue(request?.body?.contains("\"dueDate\"") == false)
assertTrue(request?.body?.contains("\"description\"") == false)
}
}
@Test
fun createCard_returnsCreatedRefWithNullPublicIdWhenResponseHasNoId() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards",
method = "POST",
status = 200,
responseBody = """{"card":{"title":"Card without id"}}""",
)
val result = HttpKanbnApiClient().createCard(
baseUrl = server.baseUrl,
apiKey = "api",
listPublicId = "list-1",
title = "Card",
description = null,
dueDate = null,
tagPublicIds = emptyList(),
)
assertTrue(result is BoardsApiResult.Success<*>)
val createdRef = (result as BoardsApiResult.Success<*>).value as CreatedEntityRef
assertNull(createdRef.publicId)
}
}
@Test
fun createCard_parsesCreatedRefPublicId_fromPublicIdPublic_idAndIdKeys() = runTest {
TestServer().use { server ->
server.registerSequence(
path = "/api/v1/cards",
method = "POST",
responses = listOf(
200 to """{"card":{"publicId":"card-from-publicId"}}""",
200 to """{"card":{"public_id":"card-from-public_id"}}""",
200 to """{"card":{"id":"card-from-id"}}""",
),
)
val client = HttpKanbnApiClient()
val first = client.createCard(server.baseUrl, "api", "list-1", "Card A", null, null, emptyList())
val second = client.createCard(server.baseUrl, "api", "list-1", "Card B", null, null, emptyList())
val third = client.createCard(server.baseUrl, "api", "list-1", "Card C", null, null, emptyList())
assertEquals("card-from-publicId", (first as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
assertEquals("card-from-public_id", (second as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
assertEquals("card-from-id", (third as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
}
}
@Test
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
TestServer().use { server ->

View File

@@ -6,6 +6,15 @@ import org.junit.Test
class BoardDetailModelsTest {
@Test
fun createdEntityRef_allowsMissingPublicIdForFallbackVerificationPath() {
val withPublicId = CreatedEntityRef(publicId = "card-123")
val withoutPublicId = CreatedEntityRef(publicId = null)
assertEquals("card-123", withPublicId.publicId)
assertNull(withoutPublicId.publicId)
}
@Test
fun boardDetailModelsExposeRequiredFields() {
val tag = BoardTagSummary(

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.test.runTest
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -207,6 +208,156 @@ class BoardDetailRepositoryTest {
assertEquals("New title", apiClient.lastListTitle)
}
@Test
fun createCard_callsApiWithPublicIdsAndTopIndex() = runTest {
val apiClient = FakeBoardDetailApiClient()
val repository = createRepository(apiClient = apiClient)
val result = repository.createCard(
listPublicId = " list-1 ",
title = " Card title ",
description = " Description ",
dueDate = LocalDate.of(2026, 3, 16),
tagPublicIds = listOf(" tag-1 ", "", "tag-2", "tag-1", " ", " tag-2"),
)
assertTrue(result is BoardsApiResult.Success<*>)
assertEquals("list-1", apiClient.lastCreateCardListPublicId)
assertEquals("Card title", apiClient.lastCreateCardTitle)
assertEquals("Description", apiClient.lastCreateCardDescription)
assertEquals(LocalDate.of(2026, 3, 16), apiClient.lastCreateCardDueDate)
assertEquals(listOf("tag-1", "tag-2"), apiClient.lastCreateCardTagPublicIds)
}
@Test
fun createCard_sendsNullDescriptionWhenBlankAfterTrim() = runTest {
val apiClient = FakeBoardDetailApiClient()
val repository = createRepository(apiClient = apiClient)
val result = repository.createCard(
listPublicId = "list-1",
title = "Card",
description = " ",
dueDate = null,
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 createListRejectsBlankBoardPublicId() = runTest {
val repository = createRepository()
val result = repository.createList(boardPublicId = " ", title = "New List")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Board id is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun createListDelegatesToApiWithTrimmedIds() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
boardDetailResult = BoardsApiResult.Success(
BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(id = "list-1", title = "One", cards = emptyList()),
BoardListDetail(id = "list-2", title = "Two", cards = emptyList()),
BoardListDetail(id = "list-3", title = "Three", cards = emptyList()),
),
),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.createList(boardPublicId = " board-1 ", title = " New List ")
assertTrue(result is BoardsApiResult.Success<*>)
assertEquals("board-1", apiClient.lastBoardId)
assertEquals("board-1", apiClient.lastCreateListBoardPublicId)
assertEquals("New List", apiClient.lastCreateListTitle)
assertEquals(3, apiClient.lastCreateListAppendIndex)
}
@Test
fun createListPropagatesFailureWhenBoardDetailPrefetchFails() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
boardDetailResult = BoardsApiResult.Failure("Cannot load board")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.createList(boardPublicId = "board-1", title = "New List")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Cannot load board", (result as BoardsApiResult.Failure).message)
assertEquals("board-1", apiClient.lastBoardId)
assertEquals(null, apiClient.lastCreateListBoardPublicId)
}
@Test
fun createCardRejectsBlankListPublicId() = runTest {
val repository = createRepository()
val result = repository.createCard(
listPublicId = " ",
title = "Card",
description = null,
dueDate = null,
tagPublicIds = emptyList(),
)
assertTrue(result is BoardsApiResult.Failure)
assertEquals("List id is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun createCardRejectsBlankTitle() = runTest {
val repository = createRepository()
val result = repository.createCard(
listPublicId = "list-1",
title = " ",
description = null,
dueDate = null,
tagPublicIds = emptyList(),
)
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Card title is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun getBoardDetailValidatesBoardId() = runTest {
val repository = createRepository()
@@ -454,6 +605,8 @@ class BoardDetailRepositoryTest {
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
var boardDetailResult: BoardsApiResult<BoardDetail> = BoardsApiResult.Success(sampleBoardDetail())
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("list-new"))
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("card-new"))
var moveOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var labelByIdResults: Map<String, BoardsApiResult<LabelDetail>> = emptyMap()
@@ -466,6 +619,14 @@ 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
var lastCreateCardDueDate: LocalDate? = null
var lastCreateCardTagPublicIds: List<String> = emptyList()
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
@@ -497,6 +658,36 @@ class BoardDetailRepositoryTest {
return renameListResult
}
override suspend fun createList(
baseUrl: String,
apiKey: String,
boardPublicId: String,
title: String,
appendIndex: Int,
): BoardsApiResult<CreatedEntityRef> {
lastCreateListBoardPublicId = boardPublicId
lastCreateListTitle = title
lastCreateListAppendIndex = appendIndex
return createListResult
}
override suspend fun createCard(
baseUrl: String,
apiKey: String,
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> {
lastCreateCardListPublicId = listPublicId
lastCreateCardTitle = title
lastCreateCardDescription = description
lastCreateCardDueDate = dueDate
lastCreateCardTagPublicIds = tagPublicIds
return createCardResult
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,

View File

@@ -10,8 +10,11 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeoutOrNull
import java.time.LocalDate
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -49,7 +52,19 @@ class BoardDetailViewModelTest {
}
@Test
fun backPressClearsSelectionWhenSelectionActive() = runTest {
fun selectAllWithActiveFilter_selectsOnlyVisibleCardsOnCurrentPage() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
viewModel.applyFilterDialog()
viewModel.selectAllOnCurrentPage()
assertEquals(setOf("card-2"), viewModel.uiState.value.selectedCardIds)
}
@Test
fun backPressWithSelection_clearsSelectionAndReturnsTrue() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
viewModel.onCardLongPressed("card-1")
@@ -58,6 +73,344 @@ class BoardDetailViewModelTest {
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
}
@Test
fun openAddCardWhenNoLists_setsCanAddCardFalse_andMessage() = runTest {
val viewModel = newLoadedViewModel(
this,
FakeBoardDetailDataSource(),
detailWithoutLists(),
)
viewModel.openAddCardDialog()
assertFalse(viewModel.uiState.value.canAddCard)
assertEquals("Create a list first to add cards.", viewModel.uiState.value.addCardDisabledMessage)
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
}
@Test
fun applyFilters_tagAnyAndQueryAnd_keepsAllLists_filtersCardsOnly() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
viewModel.applyFilterDialog()
viewModel.openSearchDialog()
viewModel.updatePendingTitleQuery("duke")
viewModel.applySearchDialog()
val filtered = viewModel.uiState.value.filteredBoardDetail
assertEquals(2, filtered?.lists?.size)
assertEquals(listOf("card-2"), filtered?.lists?.get(0)?.cards?.map { it.id })
assertTrue(filtered?.lists?.get(1)?.cards?.isEmpty() == true)
}
@Test
fun tapActiveTagFilterIcon_clearsTagFilter_withoutOpeningDialog() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-a"))
viewModel.applyFilterDialog()
viewModel.onTagFilterIconTapped()
assertTrue(viewModel.uiState.value.activeTagFilterIds.isEmpty())
assertTrue(viewModel.uiState.value.pendingTagFilterIds.isEmpty())
assertFalse(viewModel.uiState.value.isFilterDialogOpen)
}
@Test
fun cancelFilterDialog_restoresPendingFromActive() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-a"))
viewModel.applyFilterDialog()
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
viewModel.cancelFilterDialog()
assertEquals(setOf("tag-a"), viewModel.uiState.value.activeTagFilterIds)
assertEquals(setOf("tag-a"), viewModel.uiState.value.pendingTagFilterIds)
assertFalse(viewModel.uiState.value.isFilterDialogOpen)
}
@Test
fun cancelAndSuccess_resetAddDrafts() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithSingleListAndAppendedList()),
BoardsApiResult.Success(detailWithSingleListAndNewTopCard()),
),
),
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.openAddListDialog()
viewModel.updateAddListTitle("Temp list")
viewModel.cancelAddListDialog()
assertEquals("", viewModel.uiState.value.addListTitleDraft)
assertFalse(viewModel.uiState.value.isAddListDialogOpen)
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle("Temp card")
viewModel.updateAddCardDescription("temp")
viewModel.toggleAddCardTag("tag-a")
viewModel.setDueDate(LocalDate.of(2026, 1, 2))
viewModel.cancelAddCardDialog()
assertEquals("", viewModel.uiState.value.addCardTitleDraft)
assertEquals("", viewModel.uiState.value.addCardDescriptionDraft)
assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty())
assertNull(viewModel.uiState.value.addCardDueDate)
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
viewModel.openAddListDialog()
viewModel.updateAddListTitle("New list")
viewModel.createList()
advanceUntilIdle()
assertEquals("", viewModel.uiState.value.addListTitleDraft)
assertFalse(viewModel.uiState.value.isAddListDialogOpen)
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle("New card")
viewModel.updateAddCardDescription("desc")
viewModel.toggleAddCardTag("tag-a")
viewModel.setDueDate(LocalDate.of(2026, 2, 3))
viewModel.createCard()
advanceUntilIdle()
assertEquals("", viewModel.uiState.value.addCardTitleDraft)
assertEquals("", viewModel.uiState.value.addCardDescriptionDraft)
assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty())
assertNull(viewModel.uiState.value.addCardDueDate)
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
}
@Test
fun createFailure_keepsAddDialogsOpenWithDrafts() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
createListResult = BoardsApiResult.Failure("List create failed"),
createCardResult = BoardsApiResult.Failure("Card create failed"),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.openAddListDialog()
viewModel.updateAddListTitle("My list")
viewModel.createList()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isAddListDialogOpen)
assertEquals("My list", viewModel.uiState.value.addListTitleDraft)
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle("My card")
viewModel.updateAddCardDescription("desc")
viewModel.toggleAddCardTag("tag-a")
viewModel.setDueDate(LocalDate.of(2026, 3, 4))
viewModel.createCard()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isAddCardDialogOpen)
assertEquals("My card", viewModel.uiState.value.addCardTitleDraft)
assertEquals("desc", viewModel.uiState.value.addCardDescriptionDraft)
assertEquals(setOf("tag-a"), viewModel.uiState.value.addCardSelectedTagIds)
assertEquals(LocalDate.of(2026, 3, 4), viewModel.uiState.value.addCardDueDate)
}
@Test
fun createList_blankTitle_setsLocalError_andRejectsBeforeRepositoryCall() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.openAddListDialog()
viewModel.updateAddListTitle(" ")
viewModel.createList()
advanceUntilIdle()
assertEquals(0, repository.createListCalls)
assertEquals("List title is required", viewModel.uiState.value.addListTitleError)
assertTrue(viewModel.uiState.value.isAddListDialogOpen)
val event = withTimeoutOrNull(50) { viewModel.events.first() }
assertNull(event)
viewModel.updateAddListTitle("Valid")
assertNull(viewModel.uiState.value.addListTitleError)
}
@Test
fun createCard_blankTitle_setsLocalError_andRejectsBeforeRepositoryCall() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle(" ")
viewModel.createCard()
advanceUntilIdle()
assertEquals(0, repository.createCardCalls)
assertEquals("Card title is required", viewModel.uiState.value.addCardTitleError)
assertTrue(viewModel.uiState.value.isAddCardDialogOpen)
val event = withTimeoutOrNull(50) { viewModel.events.first() }
assertNull(event)
viewModel.updateAddCardTitle("Valid")
assertNull(viewModel.uiState.value.addCardTitleError)
}
@Test
fun setDueDate_updatesCardDraftDueDate() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList())
viewModel.openAddCardDialog()
viewModel.setDueDate(LocalDate.of(2026, 4, 5))
assertEquals(LocalDate.of(2026, 4, 5), viewModel.uiState.value.addCardDueDate)
}
@Test
fun clearDueDate_resetsCardDraftDueDate() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList())
viewModel.openAddCardDialog()
viewModel.setDueDate(LocalDate.of(2026, 4, 5))
viewModel.clearDueDate()
assertNull(viewModel.uiState.value.addCardDueDate)
}
@Test
fun createListPlacementMismatch_emitsExactWarning() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithListPlacementMismatch()),
),
),
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddListDialog()
viewModel.updateAddListTitle("New list")
viewModel.createList()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"List created, but server ordering differed. Pull to refresh if needed.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun createCardPlacementMismatch_emitsExactWarning() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithCardPlacementMismatch()),
),
),
createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle("New card")
viewModel.createCard()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"Card created, but server ordering differed. Pull to refresh if needed.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun createFallbackNonDeterministic_emitsExactWarning() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithSingleListAndAppendedList()),
),
),
createListResult = BoardsApiResult.Success(CreatedEntityRef(null)),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddListDialog()
viewModel.updateAddListTitle("New list")
viewModel.createList()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"Created item could not be deterministically verified. Pull to refresh if needed.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun createSuccess_refreshFailure_emitsExactWarning() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Failure("refresh failed"),
),
),
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddListDialog()
viewModel.updateAddListTitle("New list")
viewModel.createList()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"Changes applied, but refresh failed. Pull to refresh.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun applySearch_withWhitespaceOnlyQuery_trimsToEmptyAndClearsActiveState() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openSearchDialog()
viewModel.updatePendingTitleQuery("Duke")
viewModel.applySearchDialog()
viewModel.openSearchDialog()
viewModel.updatePendingTitleQuery(" ")
viewModel.applySearchDialog()
assertEquals("", viewModel.uiState.value.activeTitleQuery)
assertEquals("", viewModel.uiState.value.pendingTitleQuery)
val filtered = viewModel.uiState.value.filteredBoardDetail
assertEquals(2, filtered?.lists?.size)
assertEquals(2, filtered?.lists?.get(0)?.cards?.size)
assertEquals(1, filtered?.lists?.get(1)?.cards?.size)
}
@Test
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
@@ -506,15 +859,26 @@ class BoardDetailViewModelTest {
private class FakeBoardDetailDataSource(
val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(),
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("list-created")),
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("card-created")),
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
var moveGate: CompletableDeferred<Unit>? = null,
var renameGate: CompletableDeferred<Unit>? = null,
) : BoardDetailDataSource {
var createListCalls: Int = 0
var createCardCalls: Int = 0
var moveCalls: Int = 0
var deleteCalls: Int = 0
var renameCalls: Int = 0
var lastCreateListBoardId: String? = null
var lastCreateListTitle: String? = null
var lastCreateCardListId: String? = null
var lastCreateCardTitle: String? = null
var lastCreateCardDescription: String? = null
var lastCreateCardDueDate: LocalDate? = null
var lastCreateCardTagIds: List<String> = emptyList()
var lastRenameListId: String? = null
var lastRenameTitle: String? = null
@@ -526,6 +890,29 @@ class BoardDetailViewModelTest {
}
}
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
createListCalls += 1
lastCreateListBoardId = boardPublicId
lastCreateListTitle = title
return createListResult
}
override suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
createCardCalls += 1
lastCreateCardListId = listPublicId
lastCreateCardTitle = title
lastCreateCardDescription = description
lastCreateCardDueDate = dueDate
lastCreateCardTagIds = tagPublicIds.toList()
return createCardResult
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
moveCalls += 1
moveGate?.await()
@@ -561,6 +948,14 @@ class BoardDetailViewModelTest {
)
}
fun detailWithoutLists(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = emptyList(),
)
}
fun detailWithTwoLists(): BoardDetail {
return BoardDetail(
id = "board-1",
@@ -583,6 +978,117 @@ class BoardDetailViewModelTest {
)
}
fun detailWithFilterFixture(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Alpha",
tags = listOf(BoardTagSummary("tag-a", "A", "#111111")),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "card-2",
title = "Duke Nukem",
tags = listOf(BoardTagSummary("tag-b", "B", "#222222")),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(
BoardCardSummary(
id = "card-3",
title = "Bravo",
tags = listOf(BoardTagSummary("tag-b", "B", "#222222")),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailWithSingleListAndAppendedList(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
),
BoardListDetail(
id = "list-new",
title = "New list",
cards = emptyList(),
),
),
)
}
fun detailWithSingleListAndNewTopCard(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary("card-new", "New card", emptyList(), null),
BoardCardSummary("card-1", "Card 1", emptyList(), null),
),
),
),
)
}
fun detailWithListPlacementMismatch(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-new",
title = "New list",
cards = emptyList(),
),
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
),
),
)
}
fun detailWithCardPlacementMismatch(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary("card-1", "Card 1", emptyList(), null),
BoardCardSummary("card-new", "New card", emptyList(), null),
),
),
),
)
}
fun detailWithThreeLists(): BoardDetail {
return BoardDetail(
id = "board-1",

View File

@@ -0,0 +1,9 @@
# 2026-03-16 Board Detail Add/Create/Filter Implementation
## Execution Verification
Recorded at: 2026-03-16T15:56:23-04:00
- `./gradlew test` - PASS
- `./gradlew connectedDebugAndroidTest` - PASS
- `./gradlew assembleDebug` - PASS