feat: add board detail batch move and delete workflows

This commit is contained in:
2026-03-16 02:33:55 -04:00
parent a7af727752
commit e72e584fd4
4 changed files with 524 additions and 13 deletions

View File

@@ -17,11 +17,13 @@ import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.color.MaterialColors
import java.text.DateFormat
import java.util.ArrayDeque
@@ -53,6 +55,7 @@ class BoardDetailFlowTest {
private lateinit var defaultDataSource: FakeBoardDetailDataSource
private var originalLocale: Locale? = null
private val observedStates = mutableListOf<space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailUiState>()
@Before
fun setUp() {
@@ -60,12 +63,14 @@ class BoardDetailFlowTest {
Intents.init()
defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList())
BoardDetailActivity.testDataSourceFactory = { defaultDataSource }
BoardDetailActivity.testUiStateObserver = { state -> observedStates += state }
}
@After
fun tearDown() {
Intents.release()
BoardDetailActivity.testDataSourceFactory = null
BoardDetailActivity.testUiStateObserver = null
originalLocale?.let { Locale.setDefault(it) }
}
@@ -251,6 +256,205 @@ class BoardDetailFlowTest {
}
}
@Test
fun moveDialogShowsListSelector() {
defaultDataSource.currentDetail = detailTwoLists()
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Move cards")).perform(click())
onView(withText(R.string.move_cards_to_list)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText("To Do")).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText("Doing")).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.cancel)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.move_cards)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun moveDisablesWhenTargetAlreadyContainsAllSelected() {
defaultDataSource.currentDetail = detailTwoLists()
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Move cards")).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
}
@Test
fun moveExcludesNoOpCardsFromPayload() {
defaultDataSource.currentDetail = detailTwoListsWithCardsOnBothPages()
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withText("Card 2")).perform(click())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("Doing")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
scenario.onActivity {
assertEquals(1, defaultDataSource.moveCalls)
assertEquals("list-2", defaultDataSource.lastMoveTargetListId)
assertEquals(setOf("card-1"), defaultDataSource.lastMoveCardIds)
}
}
@Test
fun moveConfirmDisabledWhileMutating() {
defaultDataSource.currentDetail = detailTwoLists()
defaultDataSource.moveGate = CompletableDeferred()
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("Doing")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
defaultDataSource.moveGate?.complete(Unit)
}
@Test
fun movePartialSuccessShowsWarningAndReselectsFailedCards() {
defaultDataSource.currentDetail = detailSingleListTwoCards()
defaultDataSource.moveResult = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2"),
message = "Some cards could not be moved. Please try again.",
)
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withText("Card 2")).perform(click())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("Doing")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
val last = observedStates.last()
assertEquals(setOf("card-2"), last.selectedCardIds)
}
@Test
fun moveFullFailurePreservesSelectionAndShowsError() {
defaultDataSource.currentDetail = detailTwoLists()
defaultDataSource.moveResult = CardBatchMutationResult.Failure("Move failed")
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("Doing")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
val last = observedStates.last()
assertEquals(setOf("card-1"), last.selectedCardIds)
}
@Test
fun deleteRequiresSecondConfirmation() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Delete cards")).perform(click())
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
onView(withText(R.string.delete_cards_second_confirmation)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
scenario.onActivity {
assertEquals(1, defaultDataSource.deleteCalls)
}
}
@Test
fun deleteConfirmDisabledWhileMutating() {
defaultDataSource.deleteGate = CompletableDeferred()
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Delete cards")).perform(click())
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
defaultDataSource.deleteGate?.complete(Unit)
}
@Test
fun deletePartialSuccessShowsWarningAndReselectsFailedCards() {
defaultDataSource.currentDetail = detailSingleListTwoCards()
defaultDataSource.deleteResult = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2"),
message = "Some cards could not be deleted. Please try again.",
)
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withText("Card 2")).perform(click())
onView(withContentDescription("Delete cards")).perform(click())
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
val last = observedStates.last()
assertEquals(setOf("card-2"), last.selectedCardIds)
}
@Test
fun deleteFullFailurePreservesSelectionAndShowsError() {
defaultDataSource.deleteResult = CardBatchMutationResult.Failure("Delete failed")
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Delete cards")).perform(click())
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
val last = observedStates.last()
assertEquals(setOf("card-1"), last.selectedCardIds)
}
@Test
fun selectionPersistsAcrossPagesAndSelectAllIsPageScoped() {
defaultDataSource.currentDetail = detailTwoListsTwoCardsEach()
val scenario = launchBoardDetail()
onView(withText("Todo A")).perform(longClick())
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withContentDescription("Select all")).perform(click())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("To Do")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
scenario.onActivity {
assertEquals(1, defaultDataSource.moveCalls)
assertEquals("list-1", defaultDataSource.lastMoveTargetListId)
assertEquals(setOf("doing-a", "doing-b"), defaultDataSource.lastMoveCardIds)
}
}
@Test
fun cardTapNavigatesToCardPlaceholderWithExtras() {
launchBoardDetail()
@@ -300,6 +504,19 @@ class BoardDetailFlowTest {
}
}
private fun awaitCondition(timeoutMs: Long = 4_000L, condition: () -> Boolean) {
val instrumentation = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < timeoutMs) {
instrumentation.waitForIdleSync()
if (condition()) {
return
}
Thread.sleep(50)
}
throw AssertionError("Condition not met within ${timeoutMs}ms")
}
private class FakeBoardDetailDataSource(
initialDetail: BoardDetail,
) : BoardDetailDataSource {
@@ -310,6 +527,15 @@ class BoardDetailFlowTest {
var renameGate: CompletableDeferred<Unit>? = null
var renameCalls: Int = 0
var lastRenameTitle: String? = null
var moveResult: CardBatchMutationResult = CardBatchMutationResult.Success
var deleteResult: CardBatchMutationResult = CardBatchMutationResult.Success
var moveGate: CompletableDeferred<Unit>? = null
var deleteGate: CompletableDeferred<Unit>? = null
var moveCalls: Int = 0
var deleteCalls: Int = 0
var lastMoveCardIds: Set<String> = emptySet()
var lastDeleteCardIds: Set<String> = emptySet()
var lastMoveTargetListId: String? = null
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
loadGate?.await()
@@ -321,11 +547,52 @@ class BoardDetailFlowTest {
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
return CardBatchMutationResult.Success
moveCalls += 1
lastMoveCardIds = cardIds.toSet()
lastMoveTargetListId = targetListId
moveGate?.await()
val result = moveResult
if (result is CardBatchMutationResult.Success || result is CardBatchMutationResult.PartialSuccess) {
val failedIds = if (result is CardBatchMutationResult.PartialSuccess) result.failedCardIds else emptySet()
val movedIds = cardIds.filterNot { failedIds.contains(it) }.toSet()
if (movedIds.isNotEmpty()) {
val targetList = currentDetail.lists.firstOrNull { it.id == targetListId }
if (targetList != null) {
val movedCards = currentDetail.lists.flatMap { it.cards }.filter { movedIds.contains(it.id) }
currentDetail = currentDetail.copy(
lists = currentDetail.lists.map { list ->
if (list.id == targetList.id) {
list.copy(cards = list.cards + movedCards.filter { moved -> list.cards.none { it.id == moved.id } })
} else {
list.copy(cards = list.cards.filterNot { movedIds.contains(it.id) })
}
},
)
}
}
}
return result
}
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
return CardBatchMutationResult.Success
deleteCalls += 1
lastDeleteCardIds = cardIds.toSet()
deleteGate?.await()
val result = deleteResult
if (result is CardBatchMutationResult.Success || result is CardBatchMutationResult.PartialSuccess) {
val failedIds = if (result is CardBatchMutationResult.PartialSuccess) result.failedCardIds else emptySet()
val deletedIds = cardIds.filterNot { failedIds.contains(it) }.toSet()
if (deletedIds.isNotEmpty()) {
currentDetail = currentDetail.copy(
lists = currentDetail.lists.map { list ->
list.copy(cards = list.cards.filterNot { deletedIds.contains(it.id) })
},
)
}
}
return result
}
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
@@ -403,5 +670,141 @@ class BoardDetailFlowTest {
),
)
}
fun detailTwoLists(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = emptyList(),
),
),
)
}
fun detailTwoListsWithCardsOnBothPages(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(
BoardCardSummary(
id = "card-2",
title = "Card 2",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailSingleListTwoCards(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = emptyList(),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "card-2",
title = "Card 2",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = emptyList(),
),
),
)
}
fun detailTwoListsTwoCardsEach(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "todo-a",
title = "Todo A",
tags = emptyList(),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "todo-b",
title = "Todo B",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(
BoardCardSummary(
id = "doing-a",
title = "Doing A",
tags = emptyList(),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "doing-b",
title = "Doing B",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
),
)
}
}
}

View File

@@ -9,6 +9,7 @@ import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
@@ -43,6 +44,10 @@ class BoardDetailActivity : AppCompatActivity() {
private lateinit var retryButton: Button
private var inlineTitleErrorMessage: String? = null
private var moveDialog: AlertDialog? = null
private var deleteSecondConfirmationDialog: AlertDialog? = null
private var dismissMoveDialogWhenMutationEnds: Boolean = false
private var dismissDeleteDialogWhenMutationEnds: Boolean = false
private lateinit var pagerAdapter: BoardListsPagerAdapter
@@ -197,6 +202,7 @@ class BoardDetailActivity : AppCompatActivity() {
}
private fun render(state: BoardDetailUiState) {
testUiStateObserver?.invoke(state)
supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) {
@@ -239,6 +245,54 @@ class BoardDetailActivity : AppCompatActivity() {
}
renderSelectionActions(state)
renderOpenDialogs(state)
}
private fun renderOpenDialogs(state: BoardDetailUiState) {
val activeMoveDialog = moveDialog
if (activeMoveDialog != null) {
val lists = state.boardDetail?.lists.orEmpty()
if (lists.isEmpty()) {
activeMoveDialog.dismiss()
moveDialog = null
} else {
val selectedIndex = activeMoveDialog.listView?.checkedItemPosition
?.takeIf { it in lists.indices }
?: state.currentPageIndex.coerceIn(0, lists.lastIndex)
val targetList = lists[selectedIndex]
val targetIds = targetList.cards.map { it.id }.toSet()
val canMove = !state.isMutating && (state.selectedCardIds - targetIds).isNotEmpty()
activeMoveDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = canMove
activeMoveDialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (dismissMoveDialogWhenMutationEnds && !state.isMutating) {
activeMoveDialog.dismiss()
moveDialog = null
dismissMoveDialogWhenMutationEnds = false
}
if (!state.isMutating && state.selectedCardIds.isEmpty()) {
activeMoveDialog.dismiss()
moveDialog = null
}
}
}
val activeDeleteDialog = deleteSecondConfirmationDialog
if (activeDeleteDialog != null) {
activeDeleteDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
activeDeleteDialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (dismissDeleteDialogWhenMutationEnds && !state.isMutating) {
activeDeleteDialog.dismiss()
deleteSecondConfirmationDialog = null
dismissDeleteDialogWhenMutationEnds = false
}
if (!state.isMutating && state.selectedCardIds.isEmpty()) {
activeDeleteDialog.dismiss()
deleteSecondConfirmationDialog = null
}
}
}
private fun renderSelectionActions(state: BoardDetailUiState) {
@@ -282,31 +336,62 @@ class BoardDetailActivity : AppCompatActivity() {
if (lists.isEmpty()) {
return
}
val listNames = lists.map { it.title }.toTypedArray()
var selectedIndex = viewModel.uiState.value.currentPageIndex.coerceIn(0, lists.lastIndex)
MaterialAlertDialogBuilder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.move_cards_to_list)
.setSingleChoiceItems(listNames, selectedIndex) { _, which -> selectedIndex = which }
.setSingleChoiceItems(listNames, selectedIndex) { _, which ->
selectedIndex = which
renderOpenDialogs(viewModel.uiState.value)
}
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.move_cards) { _, _ ->
.setPositiveButton(R.string.move_cards, null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
val targetId = lists[selectedIndex].id
dismissMoveDialogWhenMutationEnds = true
viewModel.moveSelectedCards(targetId)
}
.show()
renderOpenDialogs(viewModel.uiState.value)
}
dialog.setOnDismissListener {
if (moveDialog === dialog) {
moveDialog = null
}
dismissMoveDialogWhenMutationEnds = false
}
moveDialog = dialog
dialog.show()
}
private fun showDeleteCardsDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_cards_title)
.setMessage(R.string.delete_cards_confirmation)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
MaterialAlertDialogBuilder(this)
val secondDialog = MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_cards_second_confirmation)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.im_sure) { _, _ ->
.setPositiveButton(R.string.im_sure, null)
.create()
secondDialog.setOnShowListener {
secondDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
dismissDeleteDialogWhenMutationEnds = true
viewModel.deleteSelectedCards()
}
.show()
renderOpenDialogs(viewModel.uiState.value)
}
secondDialog.setOnDismissListener {
if (deleteSecondConfirmationDialog === secondDialog) {
deleteSecondConfirmationDialog = null
}
dismissDeleteDialogWhenMutationEnds = false
}
deleteSecondConfirmationDialog = secondDialog
secondDialog.show()
}
.show()
}
@@ -329,5 +414,6 @@ class BoardDetailActivity : AppCompatActivity() {
const val EXTRA_BOARD_TITLE = "extra_board_title"
var testDataSourceFactory: ((String) -> BoardDetailDataSource)? = null
var testUiStateObserver: ((BoardDetailUiState) -> Unit)? = null
}
}

View File

@@ -130,11 +130,32 @@ class BoardDetailViewModel(
}
fun moveSelectedCards(targetListId: String) {
runMutation { selectedIds -> repository.moveCards(selectedIds, targetListId) }
val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val selectedIds = snapshot.selectedCardIds
if (selectedIds.isEmpty()) {
return
}
val targetCardIds = snapshot.boardDetail
?.lists
?.firstOrNull { it.id == targetListId }
?.cards
.orEmpty()
.map { it.id }
.toSet()
val movableIds = selectedIds - targetCardIds
if (movableIds.isEmpty()) {
return
}
runMutation(selectedIds = movableIds) { ids -> repository.moveCards(ids, targetListId) }
}
fun deleteSelectedCards() {
runMutation(repository::deleteCards)
runMutation(selectedIds = _uiState.value.selectedCardIds, mutation = repository::deleteCards)
}
fun startEditingList(listId: String) {
@@ -238,14 +259,14 @@ class BoardDetailViewModel(
}
private fun runMutation(
selectedIds: Set<String>,
mutation: suspend (Set<String>) -> CardBatchMutationResult,
) {
val preMutation = _uiState.value
if (preMutation.isMutating) {
return
}
val selectedIds = preMutation.selectedCardIds
if (selectedIds.isEmpty()) {
if (preMutation.selectedCardIds.isEmpty() || selectedIds.isEmpty()) {
return
}

View File

@@ -40,6 +40,7 @@
<string name="select_all">Select all</string>
<string name="move_cards">Move cards</string>
<string name="delete_cards">Delete cards</string>
<string name="delete_cards_title">Delete selected cards</string>
<string name="move_cards_to_list">Move cards to list</string>
<string name="delete_cards_confirmation">Delete selected cards?</string>
<string name="delete_cards_second_confirmation">Are you sure you want to permanently delete the selected cards?</string>