Skip to content

Commit fa83782

Browse files
sanjaysargammikehardy
authored andcommitted
refactor: move deck operations business logic to DeckPickerViewModel
- Move 8 deck-related functions from DeckPicker to DeckPickerViewModel - Add reactive flows for UI communication (export, shortcuts, subdeck creation) - Update DeckPicker to use ViewModel methods instead of direct collection access
1 parent cdd5a88 commit fa83782

File tree

4 files changed

+175
-75
lines changed

4 files changed

+175
-75
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt

Lines changed: 38 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import com.ichi2.anki.deckpicker.DeckPickerViewModel.FlattenedDeckList
113113
import com.ichi2.anki.deckpicker.DeckPickerViewModel.StartupResponse
114114
import com.ichi2.anki.deckpicker.EmptyCardsResult
115115
import com.ichi2.anki.deckpicker.OptionsMenuState
116+
import com.ichi2.anki.deckpicker.ShortcutData
116117
import com.ichi2.anki.deckpicker.SyncIconState
117118
import com.ichi2.anki.dialogs.AsyncDialogFragment
118119
import com.ichi2.anki.dialogs.BackupPromptDialog
@@ -144,7 +145,6 @@ import com.ichi2.anki.export.ExportDialogFragment
144145
import com.ichi2.anki.introduction.CollectionPermissionScreenLauncher
145146
import com.ichi2.anki.introduction.hasCollectionStoragePermissions
146147
import com.ichi2.anki.libanki.DeckId
147-
import com.ichi2.anki.libanki.Decks
148148
import com.ichi2.anki.libanki.exception.ConfirmModSchemaException
149149
import com.ichi2.anki.libanki.sched.DeckNode
150150
import com.ichi2.anki.libanki.undoAvailable
@@ -678,6 +678,10 @@ open class DeckPicker :
678678
startActivity(destination.toIntent(this))
679679
}
680680

681+
fun onExportDeck(deckId: DeckId) {
682+
ExportDialogFragment.newInstance(deckId).show(supportFragmentManager, "exportOptions")
683+
}
684+
681685
fun onPromptUserToUpdateScheduler(op: Unit) {
682686
SchedulerUpgradeDialog(
683687
activity = this,
@@ -776,7 +780,7 @@ open class DeckPicker :
776780
}
777781

778782
fun onFocusedDeckChanged(deckId: DeckId?) {
779-
val position = deckId?.let { findDeckPosition(it) } ?: 0
783+
val position = deckId?.let { viewModel.findDeckPosition(it) } ?: 0
780784
// HACK: a small delay is required before scrolling works
781785
deckPickerBinding.decks.postDelayed({
782786
decksLayoutManager.scrollToPositionWithOffset(position, deckPickerBinding.decks.height / 2)
@@ -828,6 +832,9 @@ open class DeckPicker :
828832
viewModel.emptyCardsNotification.launchCollectionInLifecycleScope(::onCardsEmptied)
829833
viewModel.flowOfDeckCountsChanged.launchCollectionInLifecycleScope(::onDeckCountsChanged)
830834
viewModel.flowOfDestination.launchCollectionInLifecycleScope(::onDestinationChanged)
835+
viewModel.flowOfExportDeck.launchCollectionInLifecycleScope(::onExportDeck)
836+
viewModel.flowOfCreateShortcut.launchCollectionInLifecycleScope(::createIcon)
837+
viewModel.flowOfDisableShortcuts.launchCollectionInLifecycleScope(::disableDeckAndChildrenShortcuts)
831838
viewModel.onError.launchCollectionInLifecycleScope(::onError)
832839
viewModel.flowOfPromptUserToUpdateScheduler.launchCollectionInLifecycleScope(::onPromptUserToUpdateScheduler)
833840
viewModel.flowOfUndoUpdated.launchCollectionInLifecycleScope(::onUndoUpdated)
@@ -879,7 +886,7 @@ open class DeckPicker :
879886
* if fixed or given free hand to delete the shortcut with the help of API update this method and use the new one
880887
*/
881888
// TODO: it feels buggy that this is not called on all deck deletion paths
882-
disableDeckAndChildrenShortcuts(deckId)
889+
viewModel.disableDeckAndChildrenShortcuts(deckId)
883890
dismissAllDialogFragments()
884891
deleteDeck(deckId)
885892
}
@@ -894,7 +901,7 @@ open class DeckPicker :
894901
}
895902
DeckPickerContextMenuOption.CREATE_SHORTCUT -> {
896903
Timber.i("ContextMenu: Create icon for a deck")
897-
createIcon(this, deckId)
904+
viewModel.createIcon(deckId)
898905
}
899906
DeckPickerContextMenuOption.RENAME_DECK -> {
900907
Timber.i("ContextMenu: Rename deck selected")
@@ -903,7 +910,7 @@ open class DeckPicker :
903910
}
904911
DeckPickerContextMenuOption.EXPORT_DECK -> {
905912
Timber.i("ContextMenu: Export deck selected")
906-
exportDeck(deckId)
913+
viewModel.exportDeck(deckId)
907914
}
908915
DeckPickerContextMenuOption.UNBURY -> {
909916
Timber.i("ContextMenu: Unbury deck selected")
@@ -2155,40 +2162,23 @@ open class DeckPicker :
21552162
// Also forget the last deck used by the Browser
21562163
CardBrowser.clearLastDeckId()
21572164
viewModel.focusedDeck = did
2158-
val deck = getNodeByDid(did)
2159-
if (deck.hasCardsReadyToStudy()) {
2165+
2166+
// TODO: Reuse dueTree from ViewModel instead of recalculating for better performance.
2167+
val deck = withCol { sched.deckDueTree().find(did) }
2168+
if (deck?.hasCardsReadyToStudy() == true) {
21602169
openReviewerOrStudyOptions(selectionType)
21612170
return
21622171
}
21632172

2164-
if (!deck.filtered && isDeckAndSubdeckEmpty(did)) {
2173+
val isEmpty = withCol { decks.cardCount(did, includeSubdecks = true) == 0 }
2174+
if (!deck?.filtered!! && isEmpty) {
21652175
showEmptyDeckSnackbar()
21662176
updateUi()
21672177
} else {
21682178
onDeckCompleted()
21692179
}
21702180
}
21712181

2172-
/**
2173-
* Return the position of the deck in the deck list. If the deck is a child of a collapsed deck
2174-
* (i.e., not visible in the deck list), then the position of the parent deck is returned instead.
2175-
*
2176-
* An invalid deck ID will return position 0.
2177-
*/
2178-
private fun findDeckPosition(did: DeckId): Int {
2179-
deckListAdapter.currentList.forEachIndexed { index, treeNode ->
2180-
if (treeNode.did == did) {
2181-
return index
2182-
}
2183-
}
2184-
2185-
// If the deck is not in our list, we search again using the immediate parent
2186-
// If the deck is not found, return 0
2187-
val collapsedDeck = dueTree?.find(did) ?: return 0
2188-
val parent = collapsedDeck.parent?.get() ?: return 0
2189-
return findDeckPosition(parent.did)
2190-
}
2191-
21922182
/**
21932183
* @see DeckPickerViewModel.updateDeckList
21942184
*/
@@ -2199,28 +2189,16 @@ open class DeckPicker :
21992189
}
22002190
}
22012191

2202-
/**
2203-
* Get the [DeckNode] identified by [did] from [DeckAdapter].
2204-
*/
2205-
private fun DeckPicker.getNodeByDid(did: DeckId): DeckNode = deckListAdapter.currentList[findDeckPosition(did)].deckNode
2206-
2207-
fun exportDeck(did: DeckId) {
2208-
ExportDialogFragment.newInstance(did).show(supportFragmentManager, "exportOptions")
2209-
}
2210-
2211-
private fun createIcon(
2212-
context: Context,
2213-
did: DeckId,
2214-
) {
2192+
private fun createIcon(shortcutData: ShortcutData) {
22152193
// This code should not be reachable with lower versions
22162194
val shortcut =
22172195
ShortcutInfoCompat
2218-
.Builder(this, did.toString())
2196+
.Builder(this, shortcutData.deckId.toString())
22192197
.setIntent(
2220-
intentToReviewDeckFromShortcuts(context, did),
2221-
).setIcon(IconCompat.createWithResource(context, R.mipmap.ic_launcher))
2222-
.setShortLabel(Decks.basename(getColUnsafe.decks.name(did)))
2223-
.setLongLabel(getColUnsafe.decks.name(did))
2198+
intentToReviewDeckFromShortcuts(this, shortcutData.deckId),
2199+
).setIcon(IconCompat.createWithResource(this, R.mipmap.ic_launcher))
2200+
.setShortLabel(shortcutData.shortLabel)
2201+
.setLongLabel(shortcutData.longLabel)
22242202
.build()
22252203
try {
22262204
val success = ShortcutManagerCompat.requestPinShortcut(this, shortcut, null)
@@ -2240,23 +2218,25 @@ open class DeckPicker :
22402218

22412219
/** Disables the shortcut of the deck and the children belonging to it.*/
22422220
@NeedsTest("ensure collapsed decks are also deleted")
2243-
private fun disableDeckAndChildrenShortcuts(did: DeckId) {
2244-
val deckTreeDids = dueTree?.find(did)?.map { it.did.toString() } ?: listOf()
2221+
private fun disableDeckAndChildrenShortcuts(deckTreeDids: List<String>) {
22452222
val errorMessage: CharSequence = getString(R.string.deck_shortcut_doesnt_exist)
22462223
ShortcutUtils.disableShortcuts(this, deckTreeDids, errorMessage)
22472224
}
22482225

22492226
fun renameDeckDialog(did: DeckId) {
2250-
val currentName = getColUnsafe.decks.name(did)
2251-
val createDeckDialog = CreateDeckDialog(this@DeckPicker, R.string.rename_deck, CreateDeckDialog.DeckDialogType.RENAME_DECK, null)
2252-
createDeckDialog.deckName = currentName
2253-
createDeckDialog.onNewDeckCreated = {
2254-
dismissAllDialogFragments()
2255-
deckListAdapter.notifyDataSetChanged()
2256-
updateDeckList()
2257-
tryShowStudyOptionsPanel()
2227+
launchCatchingTask {
2228+
val currentName = withCol { decks.name(did) }
2229+
val createDeckDialog =
2230+
CreateDeckDialog(this@DeckPicker, R.string.rename_deck, CreateDeckDialog.DeckDialogType.RENAME_DECK, null)
2231+
createDeckDialog.deckName = currentName
2232+
createDeckDialog.onNewDeckCreated = {
2233+
dismissAllDialogFragments()
2234+
deckListAdapter.notifyDataSetChanged()
2235+
updateDeckList()
2236+
tryShowStudyOptionsPanel()
2237+
}
2238+
createDeckDialog.showDialog()
22582239
}
2259-
createDeckDialog.showDialog()
22602240
}
22612241

22622242
/**
@@ -2288,11 +2268,7 @@ open class DeckPicker :
22882268
fun rebuildFiltered(did: DeckId) {
22892269
launchCatchingTask {
22902270
withProgress(resources.getString(R.string.rebuild_filtered_deck)) {
2291-
withCol {
2292-
Timber.d("rebuildFiltered: doInBackground - RebuildCram")
2293-
decks.select(did)
2294-
sched.rebuildFilteredDeck(decks.selected())
2295-
}
2271+
viewModel.rebuildFilteredDeck(did).join()
22962272
}
22972273
updateDeckList()
22982274
tryShowStudyOptionsPanel()
@@ -2461,18 +2437,6 @@ open class DeckPicker :
24612437
}
24622438
}
24632439

2464-
/**
2465-
* Returns if the deck and its subdecks are all empty.
2466-
*
2467-
* @param did The id of a deck with no pending cards to review
2468-
*/
2469-
private suspend fun isDeckAndSubdeckEmpty(did: DeckId): Boolean {
2470-
val node = getNodeByDid(did)
2471-
return withCol {
2472-
node.all { decks.isEmpty(it.did) }
2473-
}
2474-
}
2475-
24762440
override fun getApkgFileImportResultLauncher(): ActivityResultLauncher<Intent> = apkgFileImportResultLauncher
24772441

24782442
override fun getCsvFileImportResultLauncher(): ActivityResultLauncher<Intent> = csvImportResultLauncher

AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ import com.ichi2.anki.InitialActivity
3636
import com.ichi2.anki.OnErrorListener
3737
import com.ichi2.anki.PermissionSet
3838
import com.ichi2.anki.browser.BrowserDestination
39+
import com.ichi2.anki.common.annotations.NeedsTest
3940
import com.ichi2.anki.configureRenderingMode
4041
import com.ichi2.anki.launchCatchingIO
4142
import com.ichi2.anki.libanki.CardId
4243
import com.ichi2.anki.libanki.Consts
4344
import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID
4445
import com.ichi2.anki.libanki.DeckId
46+
import com.ichi2.anki.libanki.Decks
4547
import com.ichi2.anki.libanki.sched.DeckNode
4648
import com.ichi2.anki.libanki.undoAvailable
4749
import com.ichi2.anki.libanki.undoLabel
@@ -124,7 +126,7 @@ class DeckPickerViewModel :
124126
data = tree.filterAndFlattenDisplay(filter, currentDeckId),
125127
hasSubDecks = tree.children.any { it.children.any() },
126128
)
127-
}
129+
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = FlattenedDeckList.empty)
128130

129131
/**
130132
* @see deleteDeck
@@ -134,6 +136,9 @@ class DeckPickerViewModel :
134136
val emptyCardsNotification = MutableSharedFlow<EmptyCardsResult>(extraBufferCapacity = 1)
135137
val flowOfDestination = MutableSharedFlow<Destination>(extraBufferCapacity = 1)
136138
override val onError = MutableSharedFlow<String>(extraBufferCapacity = 1)
139+
val flowOfExportDeck = MutableSharedFlow<DeckId>()
140+
val flowOfCreateShortcut = MutableSharedFlow<ShortcutData>()
141+
val flowOfDisableShortcuts = MutableSharedFlow<List<String>>()
137142

138143
/**
139144
* A notification that the study counts have changed
@@ -257,6 +262,20 @@ class DeckPickerViewModel :
257262
flowOfDeckCountsChanged.emit(Unit)
258263
}
259264

265+
/**
266+
* Rebuilds a filtered deck with its current filter settings
267+
*/
268+
@CheckResult
269+
fun rebuildFilteredDeck(deckId: DeckId): Job =
270+
viewModelScope.launch {
271+
Timber.i("rebuilding filtered deck %s", deckId)
272+
withCol {
273+
decks.select(deckId)
274+
sched.rebuildFilteredDeck(decks.selected())
275+
}
276+
flowOfDeckCountsChanged.emit(Unit)
277+
}
278+
260279
fun browseCards(deckId: DeckId) =
261280
launchCatchingIO {
262281
withCol { decks.select(deckId) }
@@ -391,6 +410,63 @@ class DeckPickerViewModel :
391410
flowOfRefreshDeckList.emit(Unit)
392411
}
393412

413+
/**
414+
* Requests export for the specified deck
415+
*/
416+
fun exportDeck(deckId: DeckId) =
417+
launchCatchingIO {
418+
flowOfExportDeck.emit(deckId)
419+
}
420+
421+
/**
422+
* Find the position of a deck in the flattened deck list.
423+
* If the deck is a child of a collapsed deck, returns the position of the parent deck.
424+
* Returns 0 if the deck is not found.
425+
*/
426+
fun findDeckPosition(deckId: DeckId): Int {
427+
val currentDeckList = flowOfDeckList.value.data
428+
currentDeckList.forEachIndexed { index, treeNode ->
429+
if (treeNode.did == deckId) {
430+
return index
431+
}
432+
}
433+
434+
// If the deck is not in our list, search using the immediate parent
435+
val collapsedDeck = dueTree?.find(deckId) ?: return 0
436+
val parent = collapsedDeck.parent?.get() ?: return 0
437+
return findDeckPosition(parent.did)
438+
}
439+
440+
/**
441+
* Prepares data for creating a deck shortcut
442+
*/
443+
fun createIcon(deckId: DeckId) =
444+
launchCatchingIO {
445+
val (shortLabel, longLabel) =
446+
withCol {
447+
val fullName = decks.name(deckId)
448+
Pair(
449+
Decks.basename(fullName),
450+
fullName,
451+
)
452+
}
453+
flowOfCreateShortcut.emit(
454+
ShortcutData(
455+
deckId = deckId,
456+
shortLabel = shortLabel,
457+
longLabel = longLabel,
458+
),
459+
)
460+
}
461+
462+
/** Disables the shortcut of the deck and the children belonging to it.*/
463+
@NeedsTest("ensure collapsed decks are also deleted")
464+
fun disableDeckAndChildrenShortcuts(deckId: DeckId) =
465+
launchCatchingIO {
466+
val deckTreeDids = dueTree?.find(deckId)?.map { it.did.toString() } ?: emptyList()
467+
flowOfDisableShortcuts.emit(deckTreeDids)
468+
}
469+
394470
sealed class StartupResponse {
395471
data class RequestPermissions(
396472
val requiredPermissions: PermissionSet,
@@ -566,6 +642,17 @@ data class EmptyCardsResult(
566642

567643
fun DeckNode.onlyHasDefaultDeck() = children.singleOrNull()?.did == DEFAULT_DECK_ID
568644

645+
/**
646+
* Data for creating a deck shortcut
647+
* @param shortLabel the basename of the deck (e.g., "Verbs" for "Language::English::Verbs")
648+
* @param longLabel the full deck name (e.g., "Language::English::Verbs")
649+
*/
650+
data class ShortcutData(
651+
val deckId: DeckId,
652+
val shortLabel: String,
653+
val longLabel: String,
654+
)
655+
569656
enum class SyncIconState {
570657
Normal,
571658
PendingChanges,

AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,8 @@ class DeckPickerTest : RobolectricTest() {
469469
deckPicker {
470470
val didA = addDeck("Deck 1")
471471
supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.CREATE_SHORTCUT, didA)
472+
// Wait for the shortcut creation to complete
473+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
472474
assertEquals(
473475
"Deck 1",
474476
ShortcutManagerCompat.getShortcuts(this, ShortcutManagerCompat.FLAG_MATCH_PINNED).first().shortLabel,

0 commit comments

Comments
 (0)