From 87279d8e03cc707f863e72f2d96f3bf0b39dc4ce Mon Sep 17 00:00:00 2001 From: lukstbit <52494258+lukstbit@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:59:32 +0200 Subject: [PATCH] Enable deck option target selection in study screen The implementation is inserted around the current code so other usages outside of the new study screen work as before. See https://github.com/lukstbit/anki/blob/d24d2e33943af2361b5a9880572b30887efcf3ee/qt/aqt/deckoptions.py#L83-L100 --- .../anki/pages/DeckOptionsDestination.kt | 28 +++++++- .../ui/windows/reviewer/ReviewerFragment.kt | 70 +++++++++++++++++-- .../ui/windows/reviewer/ReviewerViewModel.kt | 31 +++++++- .../layout/dialog_deck_options_selection.xml | 10 +++ .../res/layout/item_deck_option_selection.xml | 13 ++++ 5 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 AnkiDroid/src/main/res/layout/dialog_deck_options_selection.xml create mode 100644 AnkiDroid/src/main/res/layout/item_deck_option_selection.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptionsDestination.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptionsDestination.kt index 13624cd3b62f..6a1a74c0a83c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptionsDestination.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptionsDestination.kt @@ -23,9 +23,15 @@ import com.ichi2.anki.FilteredDeckOptions import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.utils.Destination -class DeckOptionsDestination( - private val deckId: DeckId, - private val isFiltered: Boolean, +/** + * @param options the list of deck options to present to the user before going to deck options. This + * will contain the current deck target([deckId]) plus any other possible deck targets(ex: decks of + * the current studied card) + */ +data class DeckOptionsDestination( + val deckId: DeckId, + val isFiltered: Boolean, + val options: List = emptyList(), ) : Destination { override fun toIntent(context: Context): Intent = if (isFiltered) { @@ -52,3 +58,19 @@ class DeckOptionsDestination( } } } + +/** + * True if we need to show to the user a list of decks before going to the deck options, false otherwise. + */ +val DeckOptionsDestination.haMultipleOptions: Boolean + get() = options.isNotEmpty() && options.size > 1 + +/** + * Information about a deck that appears in the list of possible deck targets when deck options are + * requested from the study screen. + */ +data class DeckOptionEntry( + val deckId: DeckId, + val name: String?, + val isFiltered: Boolean, +) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt index 23bbe6f10a63..8a56079cacb7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt @@ -26,13 +26,16 @@ import android.text.style.UnderlineSpan import android.view.KeyEvent import android.view.MenuItem import android.view.View +import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.webkit.WebView +import android.widget.ArrayAdapter import android.widget.FrameLayout import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.ActionMenuView import androidx.constraintlayout.widget.ConstraintSet @@ -51,19 +54,24 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import anki.scheduler.CardAnswer.Rating +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.shape.ShapeAppearanceModel -import com.ichi2.anki.CollectionManager +import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.DispatchKeyEventListener import com.ichi2.anki.Flag import com.ichi2.anki.R import com.ichi2.anki.cardviewer.Gesture import com.ichi2.anki.common.utils.android.isRobolectric +import com.ichi2.anki.databinding.DialogDeckOptionsSelectionBinding import com.ichi2.anki.databinding.Reviewer2Binding import com.ichi2.anki.dialogs.tags.TagsDialog import com.ichi2.anki.dialogs.tags.TagsDialogFactory import com.ichi2.anki.dialogs.tags.TagsDialogListener import com.ichi2.anki.libanki.sched.Counts import com.ichi2.anki.model.CardStateFilter +import com.ichi2.anki.pages.DeckOptionEntry +import com.ichi2.anki.pages.DeckOptionsDestination +import com.ichi2.anki.pages.haMultipleOptions import com.ichi2.anki.preferences.reviewer.ViewerAction import com.ichi2.anki.previewer.CardViewerActivity import com.ichi2.anki.previewer.CardViewerFragment @@ -86,12 +94,15 @@ import com.ichi2.anki.utils.ext.collectIn import com.ichi2.anki.utils.ext.collectLatestIn import com.ichi2.anki.utils.ext.sharedPrefs import com.ichi2.anki.utils.ext.showDialogFragment +import com.ichi2.anki.utils.ext.usingStyledAttributes import com.ichi2.anki.utils.ext.window import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.themes.Themes +import com.ichi2.utils.customView import com.ichi2.utils.dp import com.ichi2.utils.show import com.ichi2.utils.stripHtml +import com.ichi2.utils.title import com.squareup.seismic.ShakeDetector import dev.androidbroadcast.vbpd.viewBinding import kotlinx.coroutines.Job @@ -207,6 +218,21 @@ class ReviewerFragment : } viewModel.destinationFlow.collectIn(lifecycleScope) { destination -> + if (destination is DeckOptionsDestination && destination.haMultipleOptions) { + if (destination.options.any { it.name == null }) { + showSnackbar(R.string.something_wrong) + return@collectIn + } + showDeckOptionsTargetDialog(destination.options) { option -> + val updatedDestination = + destination.copy( + deckId = option.deckId, + isFiltered = option.isFiltered, + ) + startActivity(updatedDestination.toIntent(requireContext())) + } + return@collectIn + } startActivity(destination.toIntent(requireContext())) } @@ -232,6 +258,42 @@ class ReviewerFragment : } } + private fun showDeckOptionsTargetDialog( + options: List, + onDeckSelected: (DeckOptionEntry) -> Unit, + ) { + val binding = DialogDeckOptionsSelectionBinding.inflate(layoutInflater) + val normalDeckNameColor: Int = + requireContext().usingStyledAttributes(null, intArrayOf(android.R.attr.textColor)) { + getColor(0, 0) + } + val dynamicDeckNameColor: Int = + requireContext().usingStyledAttributes(null, intArrayOf(R.attr.dynDeckColor)) { + getColor(0, 0) + } + binding.deckOptionsList.adapter = + object : ArrayAdapter( + requireContext(), + R.layout.item_deck_option_selection, + options.map { it.name }, + ) { + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup, + ): View { + val rowView = super.getView(position, convertView, parent) as TextView + rowView.setTextColor(if (options[position].isFiltered) dynamicDeckNameColor else normalDeckNameColor) + rowView.setOnClickListener { onDeckSelected(options[position]) } + return rowView + } + } + MaterialAlertDialogBuilder(requireContext()).show { + title(text = TR.deckConfigWhichDeck()) + customView(binding.root) + } + } + private fun setupTypeAnswer() { binding.typeAnswerEditText.apply { setOnEditorActionListener { _, actionId, _ -> @@ -559,16 +621,16 @@ class ReviewerFragment : viewModel.stopAutoAdvance() val minutes = (timebox.secs / 60f).roundToInt() - val message = CollectionManager.TR.studyingCardStudiedIn(timebox.reps) + " " + CollectionManager.TR.studyingMinute(minutes) + val message = TR.studyingCardStudiedIn(timebox.reps) + " " + TR.studyingMinute(minutes) AlertDialog.Builder(requireContext()).show { setTitle(R.string.timebox_reached_title) setMessage(message) - setPositiveButton(CollectionManager.TR.studyingContinue()) { _, _ -> + setPositiveButton(TR.studyingContinue()) { _, _ -> Timber.i("ReviewerFragment: Timebox 'Continue'") viewModel.onPageFinished(false) } - setNegativeButton(CollectionManager.TR.studyingFinish()) { _, _ -> + setNegativeButton(TR.studyingFinish()) { _, _ -> Timber.i("ReviewerFragment: Timebox 'Finish'") requireActivity().finish() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt index c2c465e1b03a..bd47b8e637ff 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt @@ -28,14 +28,15 @@ import com.ichi2.anki.Reviewer import com.ichi2.anki.asyncIO import com.ichi2.anki.browser.BrowserDestination import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.launchCatchingIO import com.ichi2.anki.libanki.Card import com.ichi2.anki.libanki.CardId import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.libanki.redoLabel -import com.ichi2.anki.libanki.sched.Counts import com.ichi2.anki.libanki.sched.CurrentQueueState import com.ichi2.anki.libanki.undoLabel import com.ichi2.anki.noteeditor.NoteEditorLauncher @@ -43,6 +44,7 @@ import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.AnkiServer import com.ichi2.anki.pages.CardInfoDestination +import com.ichi2.anki.pages.DeckOptionEntry import com.ichi2.anki.pages.DeckOptionsDestination import com.ichi2.anki.pages.PostRequestUri import com.ichi2.anki.pages.StatisticsDestination @@ -284,14 +286,37 @@ class ReviewerViewModel( destinationFlow.emit(destination) } + @NeedsTest("verify that we show the expected deck options for the current card") private suspend fun emitDeckOptionsDestination() { val deckId = withCol { decks.getCurrentId() } - val isFiltered = withCol { decks.isFiltered(deckId) } - val destination = DeckOptionsDestination(deckId, isFiltered) + val card = currentCard.await() + // https://github.com/lukstbit/anki/blob/d24d2e33943af2361b5a9880572b30887efcf3ee/qt/aqt/deckoptions.py#L83-L100 + val extraDeckIds = mutableListOf() + if (card.oDid != 0L && card.oDid != deckId) { + extraDeckIds.add(card.oDid) + } + if (card.did != deckId) { + extraDeckIds.add(card.did) + } + val options = getDeckSelectionOptions(listOf(deckId) + extraDeckIds) + val destination = DeckOptionsDestination(deckId, options[0].isFiltered, options) Timber.i("Launching 'deck options' for deck %d", deckId) destinationFlow.emit(destination) } + // backend sorts on dyn + private suspend fun getDeckSelectionOptions(dids: List): List = + withCol { + dids + .map { deckId -> + DeckOptionEntry( + deckId = deckId, + name = decks.nameIfExists(deckId), + isFiltered = decks.isFiltered(deckId), + ) + }.sortedBy { it.isFiltered } + } + private suspend fun emitBrowseDestination() { val deckId = withCol { decks.getCurrentId() } val cardId = currentCard.await().id diff --git a/AnkiDroid/src/main/res/layout/dialog_deck_options_selection.xml b/AnkiDroid/src/main/res/layout/dialog_deck_options_selection.xml new file mode 100644 index 000000000000..dcaefc3c36c7 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/dialog_deck_options_selection.xml @@ -0,0 +1,10 @@ + + diff --git a/AnkiDroid/src/main/res/layout/item_deck_option_selection.xml b/AnkiDroid/src/main/res/layout/item_deck_option_selection.xml new file mode 100644 index 000000000000..64473f673c59 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/item_deck_option_selection.xml @@ -0,0 +1,13 @@ + +