feat: add board detail create and filter toolbar UI

This commit is contained in:
2026-03-16 14:47:31 -04:00
parent de7bb48fe2
commit b936baf564
12 changed files with 596 additions and 16 deletions

View File

@@ -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.

View File

@@ -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<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
present = menu.findItem(R.id.actionFilterByTag) != null && menu.findItem(R.id.actionSearch) != null
}
present
}
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertNotNull(menu.findItem(R.id.actionFilterByTag))
assertNotNull(menu.findItem(R.id.actionSearch))
}
}
@Test
fun selectionMode_replacesFilterSearchWithSelectionActions() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertNotNull(menu.findItem(R.id.actionSelectAll))
assertNotNull(menu.findItem(R.id.actionMoveCards))
assertNotNull(menu.findItem(R.id.actionDeleteCards))
assertNull(menu.findItem(R.id.actionFilterByTag))
assertNull(menu.findItem(R.id.actionSearch))
}
}
@Test
fun fab_isVisible_andOpensAddChooser() {
launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).check(matches(isDisplayed()))
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText("Add new list")).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText("Add new card")).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun missingBoardIdShowsBlockingDialogAndFinishes() {
val scenario = launchBoardDetail(boardId = null)
@@ -636,6 +686,20 @@ class BoardDetailFlowTest {
var lastDeleteCardIds: Set<String> = emptySet()
var lastMoveTargetListId: String? = null
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Failure("Not implemented in test fake")
}
override suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Failure("Not implemented in test fake")
}
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
loadGate?.await()
return if (loadResults.isNotEmpty()) {

View File

@@ -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<TextInputLayout>(R.id.addListTitleLayout)
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addListTitleInput)
titleLayout?.error = state.addListTitleError
titleInput?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (!state.isAddListDialogOpen) {
dialog.dismiss()
addListDialog = null
}
}
if (state.isAddCardDialogOpen && addCardDialog == null) {
showAddCardDialog(state)
}
addCardDialog?.let { dialog ->
val titleLayout = dialog.findViewById<TextInputLayout>(R.id.addCardTitleLayout)
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addCardTitleInput)
val descriptionInput = dialog.findViewById<TextInputEditText>(R.id.addCardDescriptionInput)
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<TextInputEditText>(R.id.searchTitleInput)
input?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (!state.isSearchDialogOpen) {
dialog.dismiss()
searchDialog = null
}
}
val activeMoveDialog = moveDialog
if (activeMoveDialog != null) {
val lists = state.boardDetail?.lists.orEmpty()
@@ -322,18 +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<String>()
for (i in 0 until container.childCount) {
val child = container.getChildAt(i)
if (child is CheckBox && child.isChecked) {
selected.add(tags[i].id)
}
}
viewModel.updatePendingTagFilterIds(selected)
}
}
container.addView(checkBox)
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.filter_by_tag)
.setView(dialogView)
.setNegativeButton(R.string.cancel) { _, _ -> viewModel.cancelFilterDialog() }
.setPositiveButton(R.string.filter) { _, _ -> viewModel.applyFilterDialog() }
.create()
dialog.setOnDismissListener {
if (filterDialog != null && viewModel.uiState.value.isFilterDialogOpen) {
viewModel.cancelFilterDialog()
}
filterDialog = null
}
filterDialog = dialog
dialog.show()
}
private fun showSearchDialog(state: BoardDetailUiState) {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_search_title, null)
val input: TextInputEditText = dialogView.findViewById(R.id.searchTitleInput)
input.setText(state.pendingTitleQuery)
input.doAfterTextChanged { viewModel.updatePendingTitleQuery(it?.toString().orEmpty()) }
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.search_by_title)
.setView(dialogView)
.setNegativeButton(R.string.cancel) { _, _ -> viewModel.cancelSearchDialog() }
.setPositiveButton(R.string.search) { _, _ -> viewModel.applySearchDialog() }
.create()
dialog.setOnDismissListener {
if (searchDialog != null && viewModel.uiState.value.isSearchDialogOpen) {
viewModel.cancelSearchDialog()
}
searchDialog = null
}
searchDialog = dialog
dialog.show()
}
private fun showMoveCardsDialog() {
val lists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
if (lists.isEmpty()) {

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M4,7h16v2H4zM7,11h10v2H7zM10,15h4v2h-4z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15.5,14h-0.79l-0.28,-0.27a6,6 0,1 0,-1.41,1.41l0.27,0.28v0.79L20,21.5L21.5,20zM10,14a4,4 0,1 1,0 -8a4,4 0,0 1,0 8z" />
</vector>

View File

@@ -68,4 +68,13 @@
android:text="@string/retry" />
</LinearLayout>
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/boardDetailCreateFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/board_detail_add"
app:srcCompat="@android:drawable/ic_input_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/addCardTitleLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/card_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/addCardTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapSentences|text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/addCardDescriptionInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="3" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/addListTitleLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/list_title_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/addListTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapSentences|text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/filterTagsPlaceholderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/filter_tags_placeholder"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<LinearLayout
android:id="@+id/filterTagsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/searchTitleLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/searchTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/actionFilterByTag"
android:icon="@drawable/ic_filter_list_24"
android:title="@string/filter_by_tag"
app:showAsAction="always" />
<item
android:id="@+id/actionSearch"
android:icon="@drawable/ic_search_24"
android:title="@string/search"
app:showAsAction="always" />
</menu>

View File

@@ -50,4 +50,18 @@
<string name="card_detail_placeholder_subtitle">Card detail view is coming soon.</string>
<string name="board_detail_unable_to_open_board">Unable to open board.</string>
<string name="board_detail_session_expired">Session expired. Please sign in again.</string>
<string name="board_detail_add">Add</string>
<string name="filter_by_tag">Filter by tag</string>
<string name="search">Search</string>
<string name="search_by_title">Search by title</string>
<string name="filter">Filter</string>
<string name="add_new_list">Add new list</string>
<string name="add_new_card">Add new card</string>
<string name="add_list">Add list</string>
<string name="add_card">Add card</string>
<string name="create_a_list_first_to_add_cards">Create a list first to add cards.</string>
<string name="card_title">Card title</string>
<string name="card_title_required">Card title is required</string>
<string name="description">Description</string>
<string name="filter_tags_placeholder">Tag selector will be wired in the next task.</string>
</resources>