diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index b29fae178553..6ca2af572d80 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -25,7 +25,6 @@ package com.ichi2.anki -import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration @@ -115,6 +114,7 @@ import com.ichi2.anki.deckpicker.DeckPickerViewModel.FlattenedDeckList import com.ichi2.anki.deckpicker.DeckPickerViewModel.StartupResponse import com.ichi2.anki.deckpicker.EmptyCardsResult import com.ichi2.anki.deckpicker.OptionsMenuState +import com.ichi2.anki.deckpicker.ShortcutData import com.ichi2.anki.deckpicker.SyncIconState import com.ichi2.anki.dialogs.AsyncDialogFragment import com.ichi2.anki.dialogs.BackupPromptDialog @@ -145,7 +145,6 @@ import com.ichi2.anki.export.ExportDialogFragment import com.ichi2.anki.introduction.CollectionPermissionScreenLauncher import com.ichi2.anki.introduction.hasCollectionStoragePermissions import com.ichi2.anki.libanki.DeckId -import com.ichi2.anki.libanki.Decks import com.ichi2.anki.libanki.exception.ConfirmModSchemaException import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.libanki.undoAvailable @@ -699,6 +698,10 @@ open class DeckPicker : startActivity(destination.toIntent(this)) } + fun onExportDeck(deckId: DeckId) { + ExportDialogFragment.newInstance(deckId).show(supportFragmentManager, "exportOptions") + } + fun onPromptUserToUpdateScheduler(op: Unit) { SchedulerUpgradeDialog( activity = this, @@ -800,7 +803,7 @@ open class DeckPicker : } fun onFocusedDeckChanged(deckId: DeckId?) { - val position = deckId?.let { findDeckPosition(it) } ?: 0 + val position = deckId?.let { viewModel.findDeckPosition(it) } ?: 0 // HACK: a small delay is required before scrolling works recyclerView.postDelayed({ recyclerViewLayoutManager.scrollToPositionWithOffset(position, recyclerView.height / 2) @@ -859,6 +862,9 @@ open class DeckPicker : viewModel.emptyCardsNotification.launchCollectionInLifecycleScope(::onCardsEmptied) viewModel.flowOfDeckCountsChanged.launchCollectionInLifecycleScope(::onDeckCountsChanged) viewModel.flowOfDestination.launchCollectionInLifecycleScope(::onDestinationChanged) + viewModel.flowOfExportDeck.launchCollectionInLifecycleScope(::onExportDeck) + viewModel.flowOfCreateShortcut.launchCollectionInLifecycleScope(::createIcon) + viewModel.flowOfDisableShortcuts.launchCollectionInLifecycleScope(::disableDeckAndChildrenShortcuts) viewModel.onError.launchCollectionInLifecycleScope(::onError) viewModel.flowOfPromptUserToUpdateScheduler.launchCollectionInLifecycleScope(::onPromptUserToUpdateScheduler) viewModel.flowOfUndoUpdated.launchCollectionInLifecycleScope(::onUndoUpdated) @@ -909,7 +915,7 @@ open class DeckPicker : * 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) + viewModel.disableDeckAndChildrenShortcuts(deckId) dismissAllDialogFragments() deleteDeck(deckId) } @@ -924,7 +930,7 @@ open class DeckPicker : } DeckPickerContextMenuOption.CREATE_SHORTCUT -> { Timber.i("ContextMenu: Create icon for a deck") - createIcon(this, deckId) + viewModel.createIcon(deckId) } DeckPickerContextMenuOption.RENAME_DECK -> { Timber.i("ContextMenu: Rename deck selected") @@ -933,7 +939,7 @@ open class DeckPicker : } DeckPickerContextMenuOption.EXPORT_DECK -> { Timber.i("ContextMenu: Export deck selected") - exportDeck(deckId) + viewModel.exportDeck(deckId) } DeckPickerContextMenuOption.UNBURY -> { Timber.i("ContextMenu: Unbury deck selected") @@ -2171,13 +2177,15 @@ open class DeckPicker : // Also forget the last deck used by the Browser CardBrowser.clearLastDeckId() viewModel.focusedDeck = did - val deck = getNodeByDid(did) - if (deck.hasCardsReadyToStudy()) { + + val deck = withCol { sched.deckDueTree().find(did) } + if (deck?.hasCardsReadyToStudy() == true) { openReviewerOrStudyOptions(selectionType) return } - if (!deck.filtered && isDeckAndSubdeckEmpty(did)) { + val isEmpty = withCol { decks.cardCount(did, includeSubdecks = true) == 0 } + if (!deck?.filtered!! && isEmpty) { showEmptyDeckSnackbar() updateUi() } else { @@ -2185,26 +2193,6 @@ open class DeckPicker : } } - /** - * Return the position of the deck in the deck list. If the deck is a child of a collapsed deck - * (i.e., not visible in the deck list), then the position of the parent deck is returned instead. - * - * An invalid deck ID will return position 0. - */ - private fun findDeckPosition(did: DeckId): Int { - deckListAdapter.currentList.forEachIndexed { index, treeNode -> - if (treeNode.did == did) { - return index - } - } - - // If the deck is not in our list, we search again using the immediate parent - // If the deck is not found, return 0 - val collapsedDeck = dueTree?.find(did) ?: return 0 - val parent = collapsedDeck.parent?.get() ?: return 0 - return findDeckPosition(parent.did) - } - /** * @see DeckPickerViewModel.updateDeckList */ @@ -2215,28 +2203,16 @@ open class DeckPicker : } } - /** - * Get the [DeckNode] identified by [did] from [DeckAdapter]. - */ - private fun DeckPicker.getNodeByDid(did: DeckId): DeckNode = deckListAdapter.currentList[findDeckPosition(did)].deckNode - - fun exportDeck(did: DeckId) { - ExportDialogFragment.newInstance(did).show(supportFragmentManager, "exportOptions") - } - - private fun createIcon( - context: Context, - did: DeckId, - ) { + private fun createIcon(shortcutData: ShortcutData) { // This code should not be reachable with lower versions val shortcut = ShortcutInfoCompat - .Builder(this, did.toString()) + .Builder(this, shortcutData.deckId.toString()) .setIntent( - intentToReviewDeckFromShortcuts(context, did), - ).setIcon(IconCompat.createWithResource(context, R.mipmap.ic_launcher)) - .setShortLabel(Decks.basename(getColUnsafe.decks.name(did))) - .setLongLabel(getColUnsafe.decks.name(did)) + intentToReviewDeckFromShortcuts(this, shortcutData.deckId), + ).setIcon(IconCompat.createWithResource(this, R.mipmap.ic_launcher)) + .setShortLabel(shortcutData.shortLabel) + .setLongLabel(shortcutData.longLabel) .build() try { val success = ShortcutManagerCompat.requestPinShortcut(this, shortcut, null) @@ -2254,28 +2230,27 @@ open class DeckPicker : } } - /** Disables the shortcut of the deck and the children belonging to it.*/ - @NeedsTest("ensure collapsed decks are also deleted") - private fun disableDeckAndChildrenShortcuts(did: DeckId) { - // Get the DeckId and all child DeckIds - val deckTreeDids = dueTree?.find(did)?.map { it.did.toString() } ?: listOf() + private fun disableDeckAndChildrenShortcuts(deckTreeDids: List) { val errorMessage: CharSequence = getString(R.string.deck_shortcut_doesnt_exist) ShortcutManagerCompat.disableShortcuts(this, deckTreeDids, errorMessage) } fun renameDeckDialog(did: DeckId) { - val currentName = getColUnsafe.decks.name(did) - val createDeckDialog = CreateDeckDialog(this@DeckPicker, R.string.rename_deck, CreateDeckDialog.DeckDialogType.RENAME_DECK, null) - createDeckDialog.deckName = currentName - createDeckDialog.onNewDeckCreated = { - dismissAllDialogFragments() - deckListAdapter.notifyDataSetChanged() - updateDeckList() - if (fragmented) { - loadStudyOptionsFragment() + launchCatchingTask { + val currentName = withCol { decks.name(did) } + val createDeckDialog = + CreateDeckDialog(this@DeckPicker, R.string.rename_deck, CreateDeckDialog.DeckDialogType.RENAME_DECK, null) + createDeckDialog.deckName = currentName + createDeckDialog.onNewDeckCreated = { + dismissAllDialogFragments() + deckListAdapter.notifyDataSetChanged() + updateDeckList() + if (fragmented) { + loadStudyOptionsFragment() + } } + createDeckDialog.showDialog() } - createDeckDialog.showDialog() } /** @@ -2307,11 +2282,7 @@ open class DeckPicker : fun rebuildFiltered(did: DeckId) { launchCatchingTask { withProgress(resources.getString(R.string.rebuild_filtered_deck)) { - withCol { - Timber.d("rebuildFiltered: doInBackground - RebuildCram") - decks.select(did) - sched.rebuildFilteredDeck(decks.selected()) - } + viewModel.rebuildFilteredDeck(did).join() } updateDeckList() if (fragmented) loadStudyOptionsFragment() @@ -2460,18 +2431,6 @@ open class DeckPicker : } } - /** - * Returns if the deck and its subdecks are all empty. - * - * @param did The id of a deck with no pending cards to review - */ - private suspend fun isDeckAndSubdeckEmpty(did: DeckId): Boolean { - val node = getNodeByDid(did) - return withCol { - node.all { decks.isEmpty(it.did) } - } - } - override fun getApkgFileImportResultLauncher(): ActivityResultLauncher = apkgFileImportResultLauncher override fun getCsvFileImportResultLauncher(): ActivityResultLauncher = csvImportResultLauncher 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 f6d1db1c9219..0aa61f952e5a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import anki.card_rendering.EmptyCardsReport import anki.collection.OpChanges +import anki.collection.opChanges import anki.decks.SetDeckCollapsedRequest import anki.i18n.GeneratedTranslations import anki.sync.SyncStatusResponse @@ -36,18 +37,21 @@ import com.ichi2.anki.InitialActivity import com.ichi2.anki.OnErrorListener import com.ichi2.anki.PermissionSet import com.ichi2.anki.browser.BrowserDestination +import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.configureRenderingMode import com.ichi2.anki.launchCatchingIO import com.ichi2.anki.libanki.CardId import com.ichi2.anki.libanki.Consts import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.libanki.Decks import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.libanki.undoAvailable import com.ichi2.anki.libanki.undoLabel import com.ichi2.anki.libanki.utils.extend import com.ichi2.anki.noteeditor.NoteEditorLauncher import com.ichi2.anki.notetype.ManageNoteTypesDestination +import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.DeckOptionsDestination import com.ichi2.anki.performBackupInBackground @@ -124,7 +128,7 @@ class DeckPickerViewModel : data = tree.filterAndFlattenDisplay(filter, currentDeckId), hasSubDecks = tree.children.any { it.children.any() }, ) - } + }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = FlattenedDeckList.empty) /** * @see deleteDeck @@ -133,6 +137,10 @@ class DeckPickerViewModel : val deckDeletedNotification = MutableSharedFlow() val emptyCardsNotification = MutableSharedFlow() val flowOfDestination = MutableSharedFlow() + val flowOfSubDeckCreated = MutableSharedFlow() + val flowOfExportDeck = MutableSharedFlow() + val flowOfCreateShortcut = MutableSharedFlow() + val flowOfDisableShortcuts = MutableSharedFlow>() override val onError = MutableSharedFlow() /** @@ -250,6 +258,20 @@ class DeckPickerViewModel : flowOfDeckCountsChanged.emit(Unit) } + /** + * Rebuilds a filtered deck with its current filter settings + */ + @CheckResult + fun rebuildFilteredDeck(deckId: DeckId): Job = + viewModelScope.launch { + Timber.i("rebuilding filtered deck %s", deckId) + withCol { + decks.select(deckId) + sched.rebuildFilteredDeck(decks.selected()) + } + flowOfDeckCountsChanged.emit(Unit) + } + fun browseCards(deckId: DeckId) = launchCatchingIO { withCol { decks.select(deckId) } @@ -380,6 +402,76 @@ class DeckPickerViewModel : flowOfRefreshDeckList.emit(Unit) } + /** + * Notifies that a subdeck has been created and UI should be refreshed + */ + fun onSubDeckCreated() { + ChangeManager.notifySubscribers( + opChanges { + deck = true + studyQueues = true + }, + initiator = this, + ) + } + + /** + * Requests export for the specified deck + */ + fun exportDeck(deckId: DeckId) = + launchCatchingIO { + flowOfExportDeck.emit(deckId) + } + + /** + * Find the position of a deck in the flattened deck list. + * If the deck is a child of a collapsed deck, returns the position of the parent deck. + * Returns 0 if the deck is not found. + */ + fun findDeckPosition(deckId: DeckId): Int { + val currentDeckList = flowOfDeckList.value.data + currentDeckList.forEachIndexed { index, treeNode -> + if (treeNode.did == deckId) { + return index + } + } + + // If the deck is not in our list, search using the immediate parent + val collapsedDeck = dueTree?.find(deckId) ?: return 0 + val parent = collapsedDeck.parent?.get() ?: return 0 + return findDeckPosition(parent.did) + } + + /** + * Prepares data for creating a deck shortcut + */ + fun createIcon(deckId: DeckId) = + launchCatchingIO { + val (shortLabel, longLabel) = + withCol { + val fullName = decks.name(deckId) + Pair( + Decks.basename(fullName), + fullName, + ) + } + flowOfCreateShortcut.emit( + ShortcutData( + deckId = deckId, + shortLabel = shortLabel, + longLabel = longLabel, + ), + ) + } + + /** Disables the shortcut of the deck and the children belonging to it.*/ + @NeedsTest("ensure collapsed decks are also deleted") + fun disableDeckAndChildrenShortcuts(deckId: DeckId) = + launchCatchingIO { + val deckTreeDids = dueTree?.find(deckId)?.map { it.did.toString() } ?: emptyList() + flowOfDisableShortcuts.emit(deckTreeDids) + } + sealed class StartupResponse { data class RequestPermissions( val requiredPermissions: PermissionSet, @@ -555,6 +647,17 @@ data class EmptyCardsResult( fun DeckNode.onlyHasDefaultDeck() = children.singleOrNull()?.did == DEFAULT_DECK_ID +/** + * Data for creating a deck shortcut + * @param shortLabel the basename of the deck (e.g., "Verbs" for "Language::English::Verbs") + * @param longLabel the full deck name (e.g., "Language::English::Verbs") + */ +data class ShortcutData( + val deckId: DeckId, + val shortLabel: String, + val longLabel: String, +) + enum class SyncIconState { Normal, PendingChanges, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index b61bad5f667f..e636f32d8625 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -38,6 +38,7 @@ import com.ichi2.testutils.ext.addBasicNoteWithOp import com.ichi2.testutils.ext.menu import com.ichi2.testutils.grantWritePermissions import com.ichi2.testutils.revokeWritePermissions +import kotlinx.coroutines.test.advanceUntilIdle import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.containsString @@ -502,6 +503,9 @@ class DeckPickerTest : RobolectricTest() { startActivityNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()).run { val didA = addDeck("Deck 1") supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.CREATE_SHORTCUT, didA) + advanceUntilIdle() + // Wait for the shortcut creation to complete + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() assertEquals( "Deck 1", ShortcutManagerCompat.getShortcuts(this, ShortcutManagerCompat.FLAG_MATCH_PINNED).first().shortLabel, diff --git a/libanki/src/test/java/com/ichi2/anki/libanki/PythonTypesTest.kt b/libanki/src/test/java/com/ichi2/anki/libanki/PythonTypesTest.kt new file mode 100644 index 000000000000..e91d74cf43e9 --- /dev/null +++ b/libanki/src/test/java/com/ichi2/anki/libanki/PythonTypesTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Sanjay Sargam + * + * 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 . + */ + +package com.ichi2.anki.libanki + +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test + +class PythonTypesTest { + @Test + fun test_deckId_toString_default_deck() { + val deckId: DeckId = 1L + assertThat(deckId.toString(), equalTo("1")) + } + + @Test + fun test_deckId_toString_user_created_deck() { + val deckId: DeckId = 1428219222352L + assertThat(deckId.toString(), equalTo("1428219222352")) + } + + @Test + fun test_deckId_toString_large_number() { + val deckId: DeckId = Long.MAX_VALUE + assertThat(deckId.toString(), equalTo("9223372036854775807")) + } + + @Test + fun test_deckId_toString_minimum_value() { + val deckId: DeckId = Long.MIN_VALUE + assertThat(deckId.toString(), equalTo("-9223372036854775808")) + } +}