Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improvement: introduce DeckPickerViewModel #17690

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.ichi2.anim.ActivityTransitionAnimation
Expand All @@ -54,6 +55,7 @@ import com.ichi2.anki.android.input.Shortcut
import com.ichi2.anki.android.input.ShortcutGroup
import com.ichi2.anki.android.input.ShortcutGroupProvider
import com.ichi2.anki.android.input.shortcut
import com.ichi2.anki.common.utils.android.isRobolectric
import com.ichi2.anki.dialogs.AsyncDialogFragment
import com.ichi2.anki.dialogs.DatabaseErrorDialog
import com.ichi2.anki.dialogs.DatabaseErrorDialog.CustomExceptionData
Expand All @@ -74,7 +76,11 @@ import com.ichi2.compat.customtabs.CustomTabsHelper
import com.ichi2.libanki.Collection
import com.ichi2.themes.Themes
import com.ichi2.utils.AdaptionUtil
import com.ichi2.utils.HandlerUtils
import com.ichi2.utils.KotlinCleanup
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import androidx.browser.customtabs.CustomTabsIntent.Builder as CustomTabsIntentBuilder

Expand Down Expand Up @@ -716,6 +722,22 @@ open class AnkiActivity :
return false
}

// TODO: Move this to an extension method once we have context parameters
protected fun <T> Flow<T>.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) {
lifecycleScope.launch {
this@launchCollectionInLifecycleScope.collect {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using repeateOnLifecycle.

if (isRobolectric) {
// hack: lifecycleScope/runOnUiThread do not handle our
// test dispatcher overriding both IO and Main
// in tests, waitForAsyncTasksToComplete may be required.
HandlerUtils.postOnNewHandler { runBlocking { block(it) } }
} else {
block(it)
}
}
}
}

override val shortcuts
get(): ShortcutGroup? = null

Expand Down
19 changes: 0 additions & 19 deletions AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ import com.ichi2.anki.browser.SaveSearchResult
import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository
import com.ichi2.anki.browser.getLabel
import com.ichi2.anki.browser.toCardBrowserLaunchOptions
import com.ichi2.anki.common.utils.android.isRobolectric
import com.ichi2.anki.dialogs.BrowserOptionsDialog
import com.ichi2.anki.dialogs.CardBrowserMySearchesDialog
import com.ichi2.anki.dialogs.CardBrowserMySearchesDialog.Companion.newInstance
Expand Down Expand Up @@ -111,17 +110,14 @@ import com.ichi2.libanki.QueueType
import com.ichi2.libanki.SortOrder
import com.ichi2.libanki.undoableOp
import com.ichi2.ui.CardBrowserSearchView
import com.ichi2.utils.HandlerUtils
import com.ichi2.utils.KotlinCleanup
import com.ichi2.utils.LanguageUtil
import com.ichi2.utils.TagsUtil.getUpdatedTags
import com.ichi2.utils.increaseHorizontalPaddingOfOverflowMenuIcons
import com.ichi2.widget.WidgetStatus.updateInBackground
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.ankiweb.rsdroid.RustCleanup
import net.ankiweb.rsdroid.Translations
Expand Down Expand Up @@ -1900,21 +1896,6 @@ open class CardBrowser :
viewModel: CardBrowserViewModel,
): Intent = NoteEditorLauncher.AddNoteFromCardBrowser(viewModel).getIntent(context)
}

private fun <T> Flow<T>.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) {
lifecycleScope.launch {
this@launchCollectionInLifecycleScope.collect {
if (isRobolectric) {
// hack: lifecycleScope/runOnUiThread do not handle our
// test dispatcher overriding both IO and Main
// in tests, waitForAsyncTasksToComplete may be required.
HandlerUtils.postOnNewHandler { runBlocking { block(it) } }
} else {
block(it)
}
}
}
}
}

suspend fun searchForRows(
Expand Down
80 changes: 37 additions & 43 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
Expand Down Expand Up @@ -104,6 +105,8 @@ 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
import com.ichi2.anki.dialogs.ConfirmationDialog
Expand Down Expand Up @@ -166,7 +169,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
Expand Down Expand Up @@ -249,6 +251,8 @@ open class DeckPicker :
CsvImportResultLauncherProvider,
CollectionPermissionScreenLauncher,
ExportDialogsFactoryProvider {
val viewModel: DeckPickerViewModel by viewModels()

// Short animation duration from system
private var shortAnimDuration = 0
private var backButtonPressedToExit = false
Expand Down Expand Up @@ -320,14 +324,6 @@ open class DeckPicker :
*/
private var syncOnResume = false

/**
* 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
* deck.
*/
@VisibleForTesting
internal var focusedDeck: DeckId = 0

private var toolbarSearchItem: MenuItem? = null
private var toolbarSearchView: AccessibleSearchView? = null
private lateinit var customStudyDialogFactory: CustomStudyDialogFactory
Expand Down Expand Up @@ -621,6 +617,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 =
Expand Down Expand Up @@ -658,8 +666,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")
Expand Down Expand Up @@ -1154,8 +1164,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
}
Expand Down Expand Up @@ -1449,7 +1460,7 @@ open class DeckPicker :
if (event.isShiftPressed) {
// Shortcut: Shift + DEL - Delete deck without confirmation dialog
Timber.i("Shift+DEL: Deck deck without confirmation")
deleteDeck(focusedDeck)
deleteDeck(viewModel.focusedDeck)
} else {
// Shortcut: DEL
Timber.i("Delete Deck from keypress")
Expand All @@ -1464,7 +1475,7 @@ open class DeckPicker :
// that is, when it appears in the trailing study option fragment
if (fragmented) {
Timber.i("Rename Deck from keypress")
renameDeckDialog(focusedDeck)
renameDeckDialog(viewModel.focusedDeck)
return true
}
}
Expand Down Expand Up @@ -1515,15 +1526,15 @@ open class DeckPicker :
val (deckName, totalCards, isFilteredDeck) =
withCol {
Triple(
decks.name(focusedDeck),
decks.cardCount(focusedDeck, includeSubdecks = true),
decks.isFiltered(focusedDeck),
decks.name(viewModel.focusedDeck),
Copy link
Member Author

@david-allison david-allison Dec 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SanjaySargam showDeleteDeckConfirmationDialog is only used when pressing 'DEL', in all other cases, we delete unconditionally and show an 'undo' snackbar

Was this intentional?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is intentional.

            KeyEvent.KEYCODE_DEL -> {
                // This action on a deck should only occur when the user see the deck name very clearly,
                // that is, when it appears in the trailing study option fragment
                if (fragmented) {
                    if (event.isShiftPressed) {
                        // Shortcut: Shift + DEL - Delete deck without confirmation dialog
                        Timber.i("Shift+DEL: Deck deck without confirmation")
                        deleteDeck(focusedDeck)
                    } else {
                        // Shortcut: DEL
                        Timber.i("Delete Deck from keypress")
                        showDeleteDeckConfirmationDialog()
                    }
                    return true
                }
            }

decks.cardCount(viewModel.focusedDeck, includeSubdecks = true),
decks.isFiltered(viewModel.focusedDeck),
)
}
val confirmDeleteDeckDialog =
DeckPickerConfirmDeleteDeckDialog.newInstance(
deckName = deckName,
deckId = focusedDeck,
deckId = viewModel.focusedDeck,
totalCards = totalCards,
isFilteredDeck = isFilteredDeck,
)
Expand Down Expand Up @@ -2130,7 +2141,7 @@ open class DeckPicker :
withCol { decks.select(did) }
// Also forget the last deck used by the Browser
CardBrowser.clearLastDeckId()
focusedDeck = did
viewModel.focusedDeck = did
val deck = deckListAdapter.getNodeByDid(did)
if (deck.hasCardsReadyToStudy()) {
openReviewerOrStudyOptions(selectionType)
Expand Down Expand Up @@ -2308,9 +2319,9 @@ open class DeckPicker :
Timber.e(e, "RuntimeException setting time remaining")
}
val current = withCol { decks.current().optLong("id") }
if (focusedDeck != current) {
if (viewModel.focusedDeck != current) {
scrollDecklistToDeck(current)
focusedDeck = current
viewModel.focusedDeck = current
}
}

Expand Down Expand Up @@ -2402,31 +2413,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()`.
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()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2024 David Allison <davidallisongithub@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/

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] */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is not needed, the name of the class is enough. Consider instead a simple:

@see [DeckPicker]

so we do have a reference to use to go to DeckPicker.

class DeckPickerViewModel : ViewModel() {
/**
* @see deleteDeck
* @see DeckDeletionResult
*/
val deckDeletedNotification = MutableSharedFlow<DeckDeletionResult>()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be part of an 'undoableSnackbar' flow, didn't seem useful


/**
* 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
* deck.
*/
// 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()
}
Comment on lines +55 to +79
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We really have 3:

  • Delete [collection] Selected Deck
  • Delete Focused Deck
  • Delete DeckID

At 3, we can potentially parameterize the method: added complexity in the VM, for 4/5 less lines in the Activity

But it's probably best to stop using 'Delete [collection] Selected Deck' to avoid race conditions

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we move to a ViewModel we should also look into improving the code as we go, otherwise we are just moving code around(which is still an improvement but we can do even better).

I don't see why we would have two delete methods especially when one calls the other under the hood. One delete that takes a DeckId and the calling sites obtaining that id for deck deletion would be better IMO.

}

/** 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,
)
}
6 changes: 5 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/libanki/Decks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ class Decks(
type: DeckConfigId = 0L,
): DeckId = id(name = name, create = true, type = type)!!

fun remove(deckIds: Iterable<Long>): OpChangesWithCount = col.backend.removeDecks(dids = deckIds)
/**
* Deletes one or more decks from the collection
* @return [OpChangesWithCount]: the number of cards deleted
*/
fun remove(deckIds: Iterable<DeckId>): OpChangesWithCount = col.backend.removeDecks(dids = deckIds)

/** A sorted sequence of deck names and IDs. */
@LibAnkiAlias("all_names_and_ids")
Expand Down
Loading
Loading