-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
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
base: main
Are you sure you want to change the base?
Changes from all commits
31043d7
9ba7bbf
d6d17f1
2903a0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 = | ||
|
@@ -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") | ||
|
@@ -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 | ||
} | ||
|
@@ -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") | ||
|
@@ -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 | ||
} | ||
} | ||
|
@@ -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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @SanjaySargam Was this intentional? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
) | ||
|
@@ -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) | ||
|
@@ -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 | ||
} | ||
} | ||
|
||
|
@@ -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() | ||
} | ||
} | ||
|
||
|
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] */ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
so we do have a reference to use to go to DeckPicker. |
||
class DeckPickerViewModel : ViewModel() { | ||
/** | ||
* @see deleteDeck | ||
* @see DeckDeletionResult | ||
*/ | ||
val deckDeletedNotification = MutableSharedFlow<DeckDeletionResult>() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We really have 3:
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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider using repeateOnLifecycle.