test: cover board detail create and filter user flows

This commit is contained in:
2026-03-16 15:38:34 -04:00
parent 1e5979f5c4
commit 3d4661cbfd
3 changed files with 402 additions and 5 deletions

View File

@@ -113,7 +113,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- Tapping on the filter by tag or search buttonswhen either of them is applied disables the active filter.
- When a card(s) is selected by a long press, the filter by tag and search buttons get hidden by the select all, move card and delete card buttons until all cards are deselected.
- When a card(s) is selected by a long press, the back arrow in the title bar and the back system button remove all selections.
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. Card move requests try these variants in order for compatibility across Kan.bn API versions: `PUT /api/v1/cards/{cardPublicId}` with `listPublicId`, then a GET+full-body `PUT /api/v1/cards/{cardPublicId}` payload (`title`, `description`, `index`, `listPublicId`, `dueDate`), then `PUT /api/v1/cards/{cardPublicId}` with `listId`, then `PATCH /api/v1/cards/{cardPublicId}` with `listId`. Board detail parsing now prefers public ids (`publicId`/`public_id`) over internal `id` values so follow-up card/list mutations target the correct API identifiers. Label chip border colors are hydrated from the Kan.bn `Get a label by public ID` endpoint (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night resource variants so dark mode uses light icon fills automatically. Startup blocking dialogs are shown for missing board id and missing session.
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. 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**
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.

View File

@@ -9,10 +9,12 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
@@ -28,6 +30,7 @@ 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
@@ -65,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") }
@@ -357,6 +361,203 @@ class BoardDetailFlowTest {
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)
@@ -682,12 +883,37 @@ 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> {
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(
@@ -697,7 +923,47 @@ class BoardDetailFlowTest {
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): 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> {
@@ -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,
),
),
),
),
)
}
}
}

View File

@@ -1,5 +1,6 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.app.DatePickerDialog
import android.content.Intent
import android.os.Bundle
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.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
@@ -175,7 +179,7 @@ class BoardDetailActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
toolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
onBackPressed()
}
retryButton.setOnClickListener {
viewModel.retryLoad()
@@ -286,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,
@@ -366,9 +370,15 @@ class BoardDetailActivity : AppCompatActivity() {
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) {
@@ -516,6 +526,7 @@ class BoardDetailActivity : AppCompatActivity() {
}
DrawableCompat.setTint(wrapped, tintColor)
item?.icon = wrapped
item?.iconTintList = android.content.res.ColorStateList.valueOf(tintColor)
}
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 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)
@@ -617,6 +655,30 @@ class BoardDetailActivity : AppCompatActivity() {
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)