test: cover board detail create and filter user flows
This commit is contained in:
@@ -113,7 +113,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- Tapping on the filter by tag or search buttonswhen either of them is applied disables the active filter.
|
- Tapping on the filter by tag or search buttonswhen either of them is applied disables the active filter.
|
||||||
- When a card(s) is selected by a long press, the filter by tag and search buttons get hidden by the select all, move card and delete card buttons until all cards are deselected.
|
- When a card(s) is selected by a long press, the filter by tag and search buttons get hidden by the select all, move card and delete card buttons until all cards are deselected.
|
||||||
- When a card(s) is selected by a long press, the back arrow in the title bar and the back system button remove all selections.
|
- When a card(s) is selected by a long press, the back arrow in the title bar and the back system button remove all selections.
|
||||||
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. Card move requests try these variants in order for compatibility across Kan.bn API versions: `PUT /api/v1/cards/{cardPublicId}` with `listPublicId`, then a GET+full-body `PUT /api/v1/cards/{cardPublicId}` payload (`title`, `description`, `index`, `listPublicId`, `dueDate`), then `PUT /api/v1/cards/{cardPublicId}` with `listId`, then `PATCH /api/v1/cards/{cardPublicId}` with `listId`. Board detail parsing now prefers public ids (`publicId`/`public_id`) over internal `id` values so follow-up card/list mutations target the correct API identifiers. Label chip border colors are hydrated from the Kan.bn `Get a label by public ID` endpoint (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night resource variants so dark mode uses light icon fills automatically. Startup blocking dialogs are shown for missing board id and missing session.
|
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. The FAB chooser now supports both add-list and add-card flows, including local validation, due-date pick/clear, tag multi-select, and create mutations with reload verification warnings when server ordering differs. Filter-by-tag and title-search are applied locally with AND semantics between criteria, preserve board/list structure (empty filtered lists remain visible), show active icon tint, and clear immediately when tapping an already-active filter/search action. Back arrow and system back clear selection first, and only navigate when no cards are selected. 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.
|
||||||
|
|
||||||
**Card detail view**
|
**Card detail view**
|
||||||
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
|
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import androidx.appcompat.content.res.AppCompatResources
|
|||||||
import androidx.test.core.app.ActivityScenario
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.espresso.Espresso.onView
|
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.click
|
||||||
import androidx.test.espresso.action.ViewActions.longClick
|
import androidx.test.espresso.action.ViewActions.longClick
|
||||||
import androidx.test.espresso.action.ViewActions.replaceText
|
import androidx.test.espresso.action.ViewActions.replaceText
|
||||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
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.Intents
|
||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
||||||
@@ -28,6 +30,7 @@ import com.google.android.material.color.MaterialColors
|
|||||||
import com.google.android.material.chip.Chip
|
import com.google.android.material.chip.Chip
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
import java.util.ArrayDeque
|
import java.util.ArrayDeque
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -65,6 +68,7 @@ class BoardDetailFlowTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
|
observedStates.clear()
|
||||||
MainActivity.dependencies.clear()
|
MainActivity.dependencies.clear()
|
||||||
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
|
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
|
||||||
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
|
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
|
||||||
@@ -357,6 +361,203 @@ class BoardDetailFlowTest {
|
|||||||
onView(withText("Add new card")).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
|
@Test
|
||||||
fun missingBoardIdShowsBlockingDialogAndFinishes() {
|
fun missingBoardIdShowsBlockingDialogAndFinishes() {
|
||||||
val scenario = launchBoardDetail(boardId = null)
|
val scenario = launchBoardDetail(boardId = null)
|
||||||
@@ -682,12 +883,37 @@ class BoardDetailFlowTest {
|
|||||||
var deleteGate: CompletableDeferred<Unit>? = null
|
var deleteGate: CompletableDeferred<Unit>? = null
|
||||||
var moveCalls: Int = 0
|
var moveCalls: Int = 0
|
||||||
var deleteCalls: Int = 0
|
var deleteCalls: Int = 0
|
||||||
|
var createListCalls: Int = 0
|
||||||
|
var createCardCalls: Int = 0
|
||||||
var lastMoveCardIds: Set<String> = emptySet()
|
var lastMoveCardIds: Set<String> = emptySet()
|
||||||
var lastDeleteCardIds: Set<String> = emptySet()
|
var lastDeleteCardIds: Set<String> = emptySet()
|
||||||
var lastMoveTargetListId: String? = null
|
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> {
|
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
|
||||||
return BoardsApiResult.Failure("Not implemented in test fake")
|
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(
|
override suspend fun createCard(
|
||||||
@@ -697,7 +923,47 @@ class BoardDetailFlowTest {
|
|||||||
dueDate: LocalDate?,
|
dueDate: LocalDate?,
|
||||||
tagPublicIds: Collection<String>,
|
tagPublicIds: Collection<String>,
|
||||||
): BoardsApiResult<CreatedEntityRef> {
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
return BoardsApiResult.Failure("Not implemented in test fake")
|
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> {
|
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
@@ -1026,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package space.hackenslacker.kanbn4droid.app.boarddetail
|
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||||
|
|
||||||
|
import android.app.DatePickerDialog
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -29,6 +30,9 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|||||||
import com.google.android.material.textfield.TextInputEditText
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import space.hackenslacker.kanbn4droid.app.MainActivity
|
import space.hackenslacker.kanbn4droid.app.MainActivity
|
||||||
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
|
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
|
||||||
@@ -175,7 +179,7 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
|
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
|
||||||
toolbar.setNavigationOnClickListener {
|
toolbar.setNavigationOnClickListener {
|
||||||
onBackPressedDispatcher.onBackPressed()
|
onBackPressed()
|
||||||
}
|
}
|
||||||
retryButton.setOnClickListener {
|
retryButton.setOnClickListener {
|
||||||
viewModel.retryLoad()
|
viewModel.retryLoad()
|
||||||
@@ -286,7 +290,7 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE
|
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 = {
|
val applyPagerState = {
|
||||||
pagerAdapter.submit(
|
pagerAdapter.submit(
|
||||||
lists = boardLists,
|
lists = boardLists,
|
||||||
@@ -366,9 +370,15 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
val titleLayout = dialog.findViewById<TextInputLayout>(R.id.addCardTitleLayout)
|
val titleLayout = dialog.findViewById<TextInputLayout>(R.id.addCardTitleLayout)
|
||||||
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addCardTitleInput)
|
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addCardTitleInput)
|
||||||
val descriptionInput = dialog.findViewById<TextInputEditText>(R.id.addCardDescriptionInput)
|
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
|
titleLayout?.error = state.addCardTitleError
|
||||||
titleInput?.isEnabled = !state.isMutating
|
titleInput?.isEnabled = !state.isMutating
|
||||||
descriptionInput?.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_POSITIVE)?.isEnabled = !state.isMutating
|
||||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
|
||||||
if (!state.isAddCardDialogOpen) {
|
if (!state.isAddCardDialogOpen) {
|
||||||
@@ -516,6 +526,7 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
DrawableCompat.setTint(wrapped, tintColor)
|
DrawableCompat.setTint(wrapped, tintColor)
|
||||||
item?.icon = wrapped
|
item?.icon = wrapped
|
||||||
|
item?.iconTintList = android.content.res.ColorStateList.valueOf(tintColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFabChooserDialog(state: BoardDetailUiState) {
|
private fun showFabChooserDialog(state: BoardDetailUiState) {
|
||||||
@@ -591,10 +602,37 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_card, null)
|
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_card, null)
|
||||||
val titleInput: TextInputEditText = dialogView.findViewById(R.id.addCardTitleInput)
|
val titleInput: TextInputEditText = dialogView.findViewById(R.id.addCardTitleInput)
|
||||||
val descriptionInput: TextInputEditText = dialogView.findViewById(R.id.addCardDescriptionInput)
|
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)
|
titleInput.setText(state.addCardTitleDraft)
|
||||||
descriptionInput.setText(state.addCardDescriptionDraft)
|
descriptionInput.setText(state.addCardDescriptionDraft)
|
||||||
titleInput.doAfterTextChanged { viewModel.updateAddCardTitle(it?.toString().orEmpty()) }
|
titleInput.doAfterTextChanged { viewModel.updateAddCardTitle(it?.toString().orEmpty()) }
|
||||||
descriptionInput.doAfterTextChanged { viewModel.updateAddCardDescription(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)
|
val dialog = MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(R.string.add_new_card)
|
.setTitle(R.string.add_new_card)
|
||||||
@@ -617,6 +655,30 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
dialog.show()
|
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) {
|
private fun showFilterDialog(state: BoardDetailUiState) {
|
||||||
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_filter_tags, null)
|
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_filter_tags, null)
|
||||||
val container: LinearLayout = dialogView.findViewById(R.id.filterTagsContainer)
|
val container: LinearLayout = dialogView.findViewById(R.id.filterTagsContainer)
|
||||||
|
|||||||
Reference in New Issue
Block a user