Compare commits
21 Commits
6c67628e40
...
81f95c1559
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f95c1559 | |||
| e6f47034bc | |||
| dc493f5037 | |||
| a63799138b | |||
| 28b81c96a0 | |||
| 3d4661cbfd | |||
| 1e5979f5c4 | |||
| 3247892038 | |||
| b936baf564 | |||
| de7bb48fe2 | |||
| c48cd1d525 | |||
| 6d313fdf60 | |||
| 6a18d6679a | |||
| 5e0eff37a6 | |||
| 8022647047 | |||
| 85659f070b | |||
| 7b1c51eae0 | |||
| 3d8b9e4491 | |||
| efe19c794f | |||
| 995a6dcae7 | |||
| 80d4c40f10 |
22
AGENTS.md
22
AGENTS.md
@@ -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 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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
12
app/src/main/res/drawable/ic_filter_list_24.xml
Normal file
12
app/src/main/res/drawable/ic_filter_list_24.xml
Normal 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>
|
||||
12
app/src/main/res/drawable/ic_search_24.xml
Normal file
12
app/src/main/res/drawable/ic_search_24.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
102
app/src/main/res/layout/dialog_add_card.xml
Normal file
102
app/src/main/res/layout/dialog_add_card.xml
Normal 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>
|
||||
25
app/src/main/res/layout/dialog_add_list.xml
Normal file
25
app/src/main/res/layout/dialog_add_list.xml
Normal 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>
|
||||
29
app/src/main/res/layout/dialog_filter_tags.xml
Normal file
29
app/src/main/res/layout/dialog_filter_tags.xml
Normal 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>
|
||||
25
app/src/main/res/layout/dialog_search_title.xml
Normal file
25
app/src/main/res/layout/dialog_search_title.xml
Normal 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>
|
||||
16
app/src/main/res/menu/menu_board_detail_main.xml
Normal file
16
app/src/main/res/menu/menu_board_detail_main.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user