From 3d4661cbfd54c87a80076972276448d4324fe387 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 15:38:34 -0400 Subject: [PATCH] test: cover board detail create and filter user flows --- AGENTS.md | 2 +- .../kanbn4droid/app/BoardDetailFlowTest.kt | 339 +++++++++++++++++- .../app/boarddetail/BoardDetailActivity.kt | 66 +++- 3 files changed, 402 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2a985e..e4dd7c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt index 6b41f02..0a6cb37 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -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(R.id.boardDetailPager).setCurrentItem(1, false) + } + onView(withText("Duke Mobile")).check(matches(isDisplayed())) + scenario.onActivity { activity -> + activity.findViewById(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(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(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(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(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(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? = null var moveCalls: Int = 0 var deleteCalls: Int = 0 + var createListCalls: Int = 0 + var createCardCalls: Int = 0 var lastMoveCardIds: Set = emptySet() var lastDeleteCardIds: Set = 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 = emptySet() + var createListResult: BoardsApiResult = BoardsApiResult.Success(CreatedEntityRef("created-list")) + var createCardResult: BoardsApiResult = BoardsApiResult.Success(CreatedEntityRef("created-card")) override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult { - 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, ): BoardsApiResult { - 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 { @@ -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, + ), + ), + ), + ), + ) + } } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt index 3f94cfa..ec53056 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt @@ -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(R.id.addCardTitleLayout) val titleInput = dialog.findViewById(R.id.addCardTitleInput) val descriptionInput = dialog.findViewById(R.id.addCardDescriptionInput) + val dueDateText = dialog.findViewById(R.id.addCardDueDateText) + val clearDueDateAction = dialog.findViewById(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)