diff --git a/AGENTS.md b/AGENTS.md index e2a985e..d043d76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,12 +97,10 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure". - Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API. - Long-pressing any of the buttons must show a tooltip with the button name. -- The view has a floating + button that shows a modal dialog that allows creating a new card using the Kan.bn API. - - The new card is added to the top of the currently shown list. - - The modal dialog has a field for the card's name. This field is mandatory - - Below the card name field there is a markdown-enabled text area for an optional card description. - - Below the card description field there is an optional date field to set the card's due date. - - Below the card's due date field there is an optional multi-value selector that allows choosing the card's tags from the tags available for the current board. +- The view has a floating + button that opens a chooser dialog with two options: "Add new list" and "Add new card". + - "Add new list" opens a modal dialog with a mandatory list title field and Cancel/Add actions. + - "Add new card" opens a modal dialog with mandatory title and optional description fields and Cancel/Add actions. + - When the board has no lists, the chooser disables "Add new card" and shows helper text: "Create a list first to add cards." - 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 +111,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, create FAB chooser, add-list/add-card dialogs with validation wiring, and toolbar main actions for filter/search that swap with selection actions. Filter/search dialogs are wired and filter/search action icons highlight when active. API-backed reload/reconciliation behavior is handled 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. **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 4fda0df..6b41f02 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -27,6 +27,7 @@ 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.util.ArrayDeque import java.util.Date import java.util.Locale @@ -50,6 +51,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 @@ -307,6 +309,54 @@ 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(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(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(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 missingBoardIdShowsBlockingDialogAndFinishes() { val scenario = launchBoardDetail(boardId = null) @@ -636,6 +686,20 @@ class BoardDetailFlowTest { var lastDeleteCardIds: Set = emptySet() var lastMoveTargetListId: String? = null + override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult { + return BoardsApiResult.Failure("Not implemented in test fake") + } + + override suspend fun createCard( + listPublicId: String, + title: String, + description: String?, + dueDate: LocalDate?, + tagPublicIds: Collection, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not implemented in test fake") + } + override suspend fun getBoardDetail(boardId: String): BoardsApiResult { loadGate?.await() return if (loadResults.isNotEmpty()) { 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 cb29cb3..c7f21f4 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 @@ -2,20 +2,32 @@ package space.hackenslacker.kanbn4droid.app.boarddetail 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 kotlinx.coroutines.launch import space.hackenslacker.kanbn4droid.app.MainActivity @@ -42,8 +54,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 +123,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 +167,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() { @@ -125,6 +180,9 @@ class BoardDetailActivity : AppCompatActivity() { retryButton.setOnClickListener { viewModel.retryLoad() } + createFab.setOnClickListener { + viewModel.openFabChooser() + } } private fun setupPager() { @@ -260,7 +318,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 +336,69 @@ 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(R.id.addListTitleLayout) + val titleInput = dialog.findViewById(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(R.id.addCardTitleLayout) + val titleInput = dialog.findViewById(R.id.addCardTitleInput) + val descriptionInput = dialog.findViewById(R.id.addCardDescriptionInput) + titleLayout?.error = state.addCardTitleError + titleInput?.isEnabled = !state.isMutating + descriptionInput?.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(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 +446,43 @@ 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) + + val filterItem = menu.add(Menu.NONE, R.id.actionFilterByTag, Menu.NONE, getString(R.string.filter_by_tag)).apply { + setIcon(R.drawable.ic_filter_list_24) + setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + tooltipText = getString(R.string.filter_by_tag) } + val searchItem = menu.add(Menu.NONE, R.id.actionSearch, Menu.NONE, getString(R.string.search)).apply { + setIcon(R.drawable.ic_search_24) + setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + tooltipText = getString(R.string.search) + } + tintMainMenuIcon(filterItem, state.activeTagFilterIds.isNotEmpty()) + tintMainMenuIcon(searchItem, state.activeTitleQuery.isNotBlank()) } private fun handleSelectionAction(item: MenuItem): Boolean { @@ -357,6 +506,189 @@ 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 + } + + 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) + titleInput.setText(state.addCardTitleDraft) + descriptionInput.setText(state.addCardDescriptionDraft) + titleInput.doAfterTextChanged { viewModel.updateAddCardTitle(it?.toString().orEmpty()) } + descriptionInput.doAfterTextChanged { viewModel.updateAddCardDescription(it?.toString().orEmpty()) } + + 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 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() + 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()) { diff --git a/app/src/main/res/drawable/ic_filter_list_24.xml b/app/src/main/res/drawable/ic_filter_list_24.xml new file mode 100644 index 0000000..e12b815 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_list_24.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_search_24.xml b/app/src/main/res/drawable/ic_search_24.xml new file mode 100644 index 0000000..2c85fc0 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_board_detail.xml b/app/src/main/res/layout/activity_board_detail.xml index 66ed963..f7f7dd7 100644 --- a/app/src/main/res/layout/activity_board_detail.xml +++ b/app/src/main/res/layout/activity_board_detail.xml @@ -68,4 +68,13 @@ android:text="@string/retry" /> + + diff --git a/app/src/main/res/layout/dialog_add_card.xml b/app/src/main/res/layout/dialog_add_card.xml new file mode 100644 index 0000000..f4d41a1 --- /dev/null +++ b/app/src/main/res/layout/dialog_add_card.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_add_list.xml b/app/src/main/res/layout/dialog_add_list.xml new file mode 100644 index 0000000..6f5d0d0 --- /dev/null +++ b/app/src/main/res/layout/dialog_add_list.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_filter_tags.xml b/app/src/main/res/layout/dialog_filter_tags.xml new file mode 100644 index 0000000..bac3751 --- /dev/null +++ b/app/src/main/res/layout/dialog_filter_tags.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_search_title.xml b/app/src/main/res/layout/dialog_search_title.xml new file mode 100644 index 0000000..73dd930 --- /dev/null +++ b/app/src/main/res/layout/dialog_search_title.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/menu/menu_board_detail_main.xml b/app/src/main/res/menu/menu_board_detail_main.xml new file mode 100644 index 0000000..722943b --- /dev/null +++ b/app/src/main/res/menu/menu_board_detail_main.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b74c26..e116e79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,4 +50,18 @@ Card detail view is coming soon. Unable to open board. Session expired. Please sign in again. + Add + Filter by tag + Search + Search by title + Filter + Add new list + Add new card + Add list + Add card + Create a list first to add cards. + Card title + Card title is required + Description + Tag selector will be wired in the next task.