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 318fb9a..8c4d1b1 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -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() @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(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(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? = null var renameCalls: Int = 0 var lastRenameTitle: String? = null + var moveResult: CardBatchMutationResult = CardBatchMutationResult.Success + var deleteResult: CardBatchMutationResult = CardBatchMutationResult.Success + var moveGate: CompletableDeferred? = null + var deleteGate: CompletableDeferred? = null + var moveCalls: Int = 0 + var deleteCalls: Int = 0 + var lastMoveCardIds: Set = emptySet() + var lastDeleteCardIds: Set = emptySet() + var lastMoveTargetListId: String? = null override suspend fun getBoardDetail(boardId: String): BoardsApiResult { loadGate?.await() @@ -321,11 +547,52 @@ class BoardDetailFlowTest { } override suspend fun moveCards(cardIds: Collection, 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): 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 { @@ -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, + ), + ), + ), + ), + ) + } } } 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 4185b99..305315c 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 @@ -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 } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt index fa4368a..2641eca 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt @@ -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, mutation: suspend (Set) -> CardBatchMutationResult, ) { val preMutation = _uiState.value if (preMutation.isMutating) { return } - val selectedIds = preMutation.selectedCardIds - if (selectedIds.isEmpty()) { + if (preMutation.selectedCardIds.isEmpty() || selectedIds.isEmpty()) { return } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d1956a..4451843 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,7 @@ Select all Move cards Delete cards + Delete selected cards Move cards to list Delete selected cards? Are you sure you want to permanently delete the selected cards?