diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 7b199265b5e0..e7827749ea1c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -105,6 +105,7 @@ import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.shortcut import com.ichi2.anki.deckpicker.BITMAP_BYTES_PER_PIXEL import com.ichi2.anki.deckpicker.BackgroundImage +import com.ichi2.anki.deckpicker.DeckDeletionResult import com.ichi2.anki.deckpicker.DeckPickerViewModel import com.ichi2.anki.dialogs.AsyncDialogFragment import com.ichi2.anki.dialogs.BackupPromptDialog @@ -169,7 +170,6 @@ import com.ichi2.libanki.Decks import com.ichi2.libanki.MediaCheckResult import com.ichi2.libanki.exception.ConfirmModSchemaException import com.ichi2.libanki.sched.DeckNode -import com.ichi2.libanki.undoableOp import com.ichi2.libanki.utils.TimeManager import com.ichi2.ui.AccessibleSearchView import com.ichi2.ui.BadgeDrawableBuilder @@ -618,6 +618,18 @@ open class DeckPicker : .build(), onReceiveContentListener, ) + + setupFlows() + } + + private fun setupFlows() { + fun onDeckDeleted(result: DeckDeletionResult) { + showSnackbar(result.toHumanReadableString(), Snackbar.LENGTH_SHORT) { + setAction(R.string.undo) { undo() } + } + } + + viewModel.deckDeletedNotification.launchCollectionInLifecycleScope(::onDeckDeleted) } private val onReceiveContentListener = @@ -655,8 +667,10 @@ open class DeckPicker : /* we can only disable the shortcut for now as it is restricted by Google https://issuetracker.google.com/issues/68949561?pli=1#comment4 * if fixed or given free hand to delete the shortcut with the help of API update this method and use the new one */ + // TODO: it feels buggy that this is not called on all deck deletion paths disableDeckAndChildrenShortcuts(deckId) - confirmDeckDeletion(deckId) + dismissAllDialogFragments() + deleteDeck(deckId) } DeckPickerContextMenuOption.DECK_OPTIONS -> { Timber.i("ContextMenu: Open deck options selected") @@ -1151,8 +1165,9 @@ open class DeckPicker : } R.id.action_deck_delete -> { launchCatchingTask { - val targetDeckId = withCol { decks.selected() } - confirmDeckDeletion(targetDeckId) + withProgress(resources.getString(R.string.delete_deck)) { + viewModel.deleteSelectedDeck().join() + } } return true } @@ -2394,31 +2409,14 @@ open class DeckPicker : createDeckDialog.showDialog() } - fun confirmDeckDeletion(did: DeckId): Job { - // No confirmation required, as undoable - dismissAllDialogFragments() - return deleteDeck(did) - } - /** - * Deletes the provided deck, child decks. and all cards inside. - * Use [.confirmDeckDeletion] for a confirmation dialog - * @param did the deck to delete + * Deletes the provided deck, child decks, and all cards inside. + * @param did ID of the deck to delete */ - fun deleteDeck(did: DeckId): Job = + fun deleteDeck(did: DeckId) = launchCatchingTask { - val deckName = withCol { decks.get(did)!!.name } - val changes = - withProgress(resources.getString(R.string.delete_deck)) { - undoableOp { - decks.remove(listOf(did)) - } - } - // After deletion: decks.current() reverts to Default, necessitating `focusedDeck` - // to match and avoid unnecessary scrolls in `renderPage()`. - viewModel.focusedDeck = Consts.DEFAULT_DECK_ID - showSnackbar(TR.browsingCardsDeletedWithDeckname(changes.count, deckName), Snackbar.LENGTH_SHORT) { - setAction(R.string.undo) { undo() } + withProgress(resources.getString(R.string.delete_deck)) { + viewModel.deleteDeck(did).join() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index 7eab22db684d..4c0a0d032da7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -16,12 +16,27 @@ package com.ichi2.anki.deckpicker +import androidx.annotation.CheckResult import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import anki.i18n.GeneratedTranslations +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.DeckPicker +import com.ichi2.libanki.Consts import com.ichi2.libanki.DeckId +import com.ichi2.libanki.undoableOp +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch /** ViewModel for [DeckPicker] */ class DeckPickerViewModel : ViewModel() { + /** + * @see deleteDeck + * @see DeckDeletionResult + */ + val deckDeletedNotification = MutableSharedFlow() + /** * Keep track of which deck was last given focus in the deck list. If we find that this value * has changed between deck list refreshes, we need to recenter the deck list to the new current @@ -29,4 +44,54 @@ class DeckPickerViewModel : ViewModel() { */ // TODO: This should later be handled as a Flow var focusedDeck: DeckId = 0 + + /** + * Deletes the provided deck, child decks. and all cards inside. + * + * This is a slow operation and should be inside `withProgress` + * + * @param did ID of the deck to delete + */ + @CheckResult // This is a slow operation and should be inside `withProgress` + fun deleteDeck(did: DeckId) = + viewModelScope.launch { + val deckName = withCol { decks.get(did)!!.name } + val changes = undoableOp { decks.remove(listOf(did)) } + // After deletion: decks.current() reverts to Default, necessitating `focusedDeck` + // to match and avoid unnecessary scrolls in `renderPage()`. + focusedDeck = Consts.DEFAULT_DECK_ID + + deckDeletedNotification.emit( + DeckDeletionResult(deckName = deckName, cardsDeleted = changes.count), + ) + } + + /** + * Deletes the currently selected deck + * + * This is a slow operation and should be inside `withProgress` + */ + @CheckResult + fun deleteSelectedDeck() = + viewModelScope.launch { + val targetDeckId = withCol { decks.selected() } + deleteDeck(targetDeckId).join() + } +} + +/** Result of [DeckPickerViewModel.deleteDeck] */ +data class DeckDeletionResult( + val deckName: String, + val cardsDeleted: Int, +) { + /** + * @see GeneratedTranslations.browsingCardsDeletedWithDeckname + */ + // TODO: Somewhat questionable meaning: {count} cards deleted from {deck_name}. + @CheckResult + fun toHumanReadableString() = + TR.browsingCardsDeletedWithDeckname( + count = cardsDeleted, + deckName = deckName, + ) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 528fc95bc9da..1eab22db2d67 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -209,8 +209,7 @@ class DeckPickerTest : RobolectricTest() { DeckPicker::class.java, Intent(), ) - deckPicker.confirmDeckDeletion(did) - advanceRobolectricLooperWithSleep() + deckPicker.viewModel.deleteDeck(did).join() assertThat("deck was deleted", col.decks.count(), equalTo(1)) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CreateDeckDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CreateDeckDialogTest.kt index 3bf195287cf8..943e61abc2b9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CreateDeckDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CreateDeckDialogTest.kt @@ -196,7 +196,7 @@ class CreateDeckDialogTest : RobolectricTest() { // After the last deck was created, delete a deck if (decksCount() >= 10) { - deckPicker.confirmDeckDeletion(did) + deckPicker.viewModel.deleteDeck(did).join() assertEquals(deckCounter.decrementAndGet(), decksCount()) assertEquals(deckCounter.get(), decksCount())