Skip to content

Custom Study: "Study by card state or tag" #17583

Open
@david-allison

Description

We currently use the TagsDialog for this feature, but it is incomplete compared to the functionality exposed by Anki Desktop. A UI should be designed, and this feature should be moved out of the TagsDialog

Anki Screenshots

Image

Image

AnkiDroid Screenshots

Image

Image

Partial patch
From 1a169cf40f275619874bc4a507b2dbf898432041 Mon Sep 17 00:00:00 2001
From: David Allison <[email protected]>
Date: Thu, 5 Dec 2024 22:32:30 +0000
Subject: [PATCH] TODO! feat(custom-study): use Backend tag studying code

TODOs:
* IntegerDialog
* extendLimits refactoring in wrong commit

Anki has 'Study by card state or tag'

This is:
* Select `[100]` cards from this deck
  * `[]` represents an input picker
* A list of 4 strategies: 'new', 'due', etc...
* A "choose tags" button,
  * a user can exclude tags
  * a checkbox: "Review one or more of these tags"
    * a user can specify tags which MUST be included

We now do the above, except:

* no exclusion of tags.
* The 'saved' list of tags in 'defaults' is not yet used
* Inclusion of tags is the default, and not gated behind a checkbox

Changes:
* TagsDialog had a partial implementation with 3 radio buttons
  * Add a radio button
  * Radio buttons are now vertical
  * A text label and edit button now exist to set the
* TagsDialog now returns a 'CustomStudyCramResponse'
  * Previous data, as well as a number of cards to include
* Card Browser no longer shows these radio buttons
---
 .../com/ichi2/anki/AbstractFlashcardViewer.kt |   4 +-
 .../main/java/com/ichi2/anki/CardBrowser.kt   |  18 +-
 .../main/java/com/ichi2/anki/NoteEditor.kt    |   4 +-
 .../anki/browser/CardBrowserViewModel.kt      |   5 +-
 .../customstudy/CustomStudyCramResponse.kt    |  22 +++
 .../dialogs/customstudy/CustomStudyDialog.kt  | 175 ++++++------------
 .../com/ichi2/anki/dialogs/tags/TagsDialog.kt |  97 ++++++++--
 .../anki/dialogs/tags/TagsDialogListener.kt   |  12 +-
 .../com/ichi2/anki/model/CardStateFilter.kt   |  31 ++--
 AnkiDroid/src/main/res/layout/tags_dialog.xml |  59 ++++--
 AnkiDroid/src/main/res/values/01-core.xml     |   2 -
 AnkiDroid/src/main/res/values/02-strings.xml  |   1 -
 AnkiDroid/src/main/res/values/03-dialogs.xml  |   5 -
 13 files changed, 242 insertions(+), 193 deletions(-)
 create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyCramResponse.kt

diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
index 56ad993bf129..c4306fb296bc 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
@@ -99,10 +99,10 @@ import com.ichi2.anki.cardviewer.ViewerRefresh
 import com.ichi2.anki.cardviewer.handledGamepadKeyDown
 import com.ichi2.anki.cardviewer.handledGamepadKeyUp
 import com.ichi2.anki.dialogs.TtsVoicesDialogFragment
+import com.ichi2.anki.dialogs.customstudy.CustomStudyCramResponse
 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.model.CardStateFilter
 import com.ichi2.anki.noteeditor.NoteEditorLauncher
 import com.ichi2.anki.pages.AnkiServer
 import com.ichi2.anki.pages.CongratsPage
@@ -2592,7 +2592,7 @@ abstract class AbstractFlashcardViewer :
     override fun onSelectedTags(
         selectedTags: List<String>,
         indeterminateTags: List<String>,
-        stateFilter: CardStateFilter
+        customStudyExtra: CustomStudyCramResponse
     ) {
         launchCatchingTask {
             val note = withCol { currentCard!!.note(this@withCol) }
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
index 11c3d748e532..99a1c74374ec 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
@@ -82,6 +82,7 @@ import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener
 import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck
 import com.ichi2.anki.dialogs.IntegerDialog
 import com.ichi2.anki.dialogs.SimpleMessageDialog
+import com.ichi2.anki.dialogs.customstudy.CustomStudyCramResponse
 import com.ichi2.anki.dialogs.tags.TagsDialog
 import com.ichi2.anki.dialogs.tags.TagsDialogFactory
 import com.ichi2.anki.dialogs.tags.TagsDialogListener
@@ -1691,9 +1692,9 @@ open class CardBrowser :
         deckSpinnerSelection.computeDropDownDecks(includeFiltered = false)
 
     @RustCleanup("this isn't how Desktop Anki does it")
-    override fun onSelectedTags(selectedTags: List<String>, indeterminateTags: List<String>, stateFilter: CardStateFilter) {
+    override fun onSelectedTags(selectedTags: List<String>, indeterminateTags: List<String>, customStudyExtra: CustomStudyCramResponse) {
         when (tagsDialogListenerAction) {
-            TagsDialogListenerAction.FILTER -> filterByTags(selectedTags, stateFilter)
+            TagsDialogListenerAction.FILTER -> filterByTags(selectedTags)
             TagsDialogListenerAction.EDIT_TAGS -> launchCatchingTask {
                 editSelectedCardsTags(selectedTags, indeterminateTags)
             }
@@ -1721,9 +1722,9 @@ open class CardBrowser :
         }
     }
 
-    private fun filterByTags(selectedTags: List<String>, cardState: CardStateFilter) =
+    private fun filterByTags(selectedTags: List<String>) =
         launchCatchingTask {
-            viewModel.filterByTags(selectedTags, cardState)
+            viewModel.filterByTags(selectedTags)
         }
 
     /** Updates search terms to only show cards with selected flag.  */
@@ -2343,11 +2344,16 @@ open class CardBrowser :
         renderBrowserQAParams(0, viewModel.rowCount - 1, viewModel.cards.toList())
     }
 
+    @KotlinCleanup("just call filterByTags")
     @VisibleForTesting(otherwise = VisibleForTesting.NONE)
     fun filterByTag(vararg tags: String) {
         tagsDialogListenerAction = TagsDialogListenerAction.FILTER
-        onSelectedTags(tags.toList(), emptyList(), CardStateFilter.ALL_CARDS)
-        filterByTags(tags.toList(), CardStateFilter.ALL_CARDS)
+        val unused = CustomStudyCramResponse(
+            kind = CardStateFilter.ALL_CARDS.cramKind,
+            cardLimit = 100
+        )
+        onSelectedTags(tags.toList(), emptyList(), unused)
+        filterByTags(tags.toList())
     }
 
     @VisibleForTesting
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt
index 9699ce46f326..496e09bbe01d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt
@@ -93,10 +93,10 @@ import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener
 import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck
 import com.ichi2.anki.dialogs.DiscardChangesDialog
 import com.ichi2.anki.dialogs.IntegerDialog
+import com.ichi2.anki.dialogs.customstudy.CustomStudyCramResponse
 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.model.CardStateFilter
 import com.ichi2.anki.multimedia.AudioRecordingFragment
 import com.ichi2.anki.multimedia.AudioVideoFragment
 import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT
@@ -1543,7 +1543,7 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su
     override fun onSelectedTags(
         selectedTags: List<String>,
         indeterminateTags: List<String>,
-        stateFilter: CardStateFilter
+        customStudyExtra: CustomStudyCramResponse
     ) {
         if (this.selectedTags != selectedTags) {
             isTagsEdited = true
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
index 44eb4477e5bd..e38aea619799 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
@@ -35,7 +35,6 @@ import com.ichi2.anki.Flag
 import com.ichi2.anki.PreviewerDestination
 import com.ichi2.anki.export.ExportDialogFragment.ExportType
 import com.ichi2.anki.launchCatchingIO
-import com.ichi2.anki.model.CardStateFilter
 import com.ichi2.anki.model.CardsOrNotes
 import com.ichi2.anki.model.CardsOrNotes.CARDS
 import com.ichi2.anki.model.CardsOrNotes.NOTES
@@ -684,8 +683,8 @@ class CardBrowserViewModel(
         setFilterQuery(searchTerms)
     }
 
-    suspend fun filterByTags(selectedTags: List<String>, cardState: CardStateFilter) {
-        val sb = StringBuilder(cardState.toSearch)
+    suspend fun filterByTags(selectedTags: List<String>) {
+        val sb = StringBuilder()
         // join selectedTags as "tag:$tag" with " or " between them
         val tagsConcat = selectedTags.joinToString(" or ") { tag -> "\"tag:$tag\"" }
         if (selectedTags.isNotEmpty()) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyCramResponse.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyCramResponse.kt
new file mode 100644
index 000000000000..a4b5c2f6ed9c
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyCramResponse.kt
@@ -0,0 +1,22 @@
+/*
+ *  Copyright (c) 2024 David Allison <[email protected]>
+ *
+ *  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.dialogs.customstudy
+
+import anki.scheduler.CustomStudyRequest.Cram.CramKind
+import java.io.Serializable
+
+data class CustomStudyCramResponse(val kind: CramKind, val cardLimit: Int) : Serializable
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt
index 0bbb388a8571..4046ec1aa46a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt
@@ -32,33 +32,28 @@ import androidx.appcompat.app.AlertDialog
 import androidx.core.content.edit
 import anki.scheduler.CustomStudyDefaultsResponse
 import anki.scheduler.CustomStudyRequestKt
+import anki.scheduler.CustomStudyRequestKt.cram
 import anki.scheduler.customStudyRequest
 import com.ichi2.anki.CollectionManager.TR
-import com.ichi2.anki.CollectionManager.withCol
 import com.ichi2.anki.R
 import com.ichi2.anki.analytics.AnalyticsDialogFragment
 import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption
 import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_AHEAD
+import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_CARD_STATE_OR_TAGS
 import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_FORGOT
 import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_NEW
 import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_PREVIEW
 import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_REV
-import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_TAGS
 import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyDefaults.Companion.toDomainModel
 import com.ichi2.anki.dialogs.tags.TagsDialog
 import com.ichi2.anki.dialogs.tags.TagsDialogListener
 import com.ichi2.anki.launchCatchingTask
-import com.ichi2.anki.model.CardStateFilter
 import com.ichi2.anki.preferences.sharedPrefs
-import com.ichi2.anki.showThemedToast
 import com.ichi2.anki.utils.ext.dismissAllDialogFragments
 import com.ichi2.anki.utils.ext.sharedPrefs
 import com.ichi2.anki.utils.ext.showDialogFragment
-import com.ichi2.anki.withProgress
 import com.ichi2.annotations.NeedsTest
 import com.ichi2.libanki.Collection
-import com.ichi2.libanki.Consts
-import com.ichi2.libanki.Consts.DynPriority
 import com.ichi2.libanki.Deck
 import com.ichi2.libanki.DeckId
 import com.ichi2.libanki.undoableOp
@@ -70,8 +65,6 @@ import com.ichi2.utils.listItems
 import com.ichi2.utils.negativeButton
 import com.ichi2.utils.positiveButton
 import com.ichi2.utils.title
-import net.ankiweb.rsdroid.exceptions.BackendDeckIsFilteredException
-import org.json.JSONObject
 import timber.log.Timber
 
 /**
@@ -101,18 +94,19 @@ import timber.log.Timber
  *   * Review forgotten cards [STUDY_FORGOT]
  *   * Review ahead [STUDY_AHEAD]
  *   * Preview new cards [STUDY_PREVIEW]
- *   * Study by tag [STUDY_TAGS]
+ *   * Study by card state or tags [STUDY_CARD_STATE_OR_TAGS]
+ *     * New cards only
+ *     * Due cards only
+ *     * All review cards in random order
+ *     * All cards in random order (don't reschedule)
  *
  * * An [input dialog][buildInputDialog], for a user to change and submit a [ContextMenuOption]
  *   * Example: changing the number of new cards
  *
  * #### Not Implemented
  * Anki Desktop contains the following items which are not yet implemented
- * * Study by card state or tags
- *   * New cards only
- *   * Due cards only
- *   * All review cards in random order
- *   * All cards in random order (don't reschedule)
+ * * Select tags to Exclude
+ * * Checkbox (default: false): Require one or more of these tags
  *
  * ## Nomenclature
  * Filtered decks were previously known as 'dynamic' decks, and before that: 'cram' decks
@@ -185,7 +179,7 @@ class CustomStudyDialog(
             .cancelable(true)
             .listItems(items = listIds.map { it.getTitle(resources) }) { _, index ->
                 when (listIds[index]) {
-                    STUDY_TAGS -> {
+                    STUDY_CARD_STATE_OR_TAGS -> {
                         /*
                          * This is a special Dialog for CUSTOM STUDY, where instead of only collecting a
                          * number, it is necessary to collect a list of tags. This case handles the creation
@@ -275,33 +269,6 @@ class CustomStudyDialog(
     private suspend fun customStudy(contextMenuOption: ContextMenuOption, userEntry: Int) {
         Timber.i("Custom study: $contextMenuOption; input = $userEntry")
 
-        suspend fun customStudy(block: CustomStudyRequestKt.Dsl.() -> Unit) {
-            undoableOp {
-                collection.sched.customStudy(
-                    customStudyRequest {
-                        deckId = dialogDeckId
-                        block(this)
-                    }
-                )
-            }
-        }
-        suspend fun extendLimits(block: CustomStudyRequestKt.Dsl.() -> Unit) {
-            try {
-                customStudy { block(this) }
-                customStudyListener?.onExtendStudyLimits()
-            } finally {
-                requireActivity().dismissAllDialogFragments()
-            }
-        }
-        suspend fun createCustomStudy(block: CustomStudyRequestKt.Dsl.() -> Unit) {
-            try {
-                customStudy { block(this) }
-                customStudyListener?.onCreateCustomStudySession()
-            } finally {
-                requireActivity().dismissAllDialogFragments()
-            }
-        }
-
         // save the default values (not in upstream)
         when (contextMenuOption) {
             STUDY_FORGOT -> sharedPrefs().edit { putInt("forgottenDays", userEntry) }
@@ -313,10 +280,10 @@ class CustomStudyDialog(
         when (contextMenuOption) {
             STUDY_NEW -> extendLimits { newLimitDelta = userEntry }
             STUDY_REV -> extendLimits { reviewLimitDelta = userEntry }
-            STUDY_FORGOT -> createCustomStudy { forgotDays = userEntry }
-            STUDY_AHEAD -> createCustomStudy { reviewAheadDays = userEntry }
-            STUDY_PREVIEW -> createCustomStudy { previewDays = userEntry }
-            STUDY_TAGS -> TODO("This branch has not been covered before")
+            STUDY_FORGOT -> createCustomStudySession { forgotDays = userEntry }
+            STUDY_AHEAD -> createCustomStudySession { reviewAheadDays = userEntry }
+            STUDY_PREVIEW -> createCustomStudySession { previewDays = userEntry }
+            STUDY_CARD_STATE_OR_TAGS -> TODO("This branch has not been covered before")
         }
     }
 
@@ -325,22 +292,18 @@ class CustomStudyDialog(
      * Generates the search screen for the custom study deck.
      */
     @NeedsTest("14537: limit to particular tags")
-    override fun onSelectedTags(selectedTags: List<String>, indeterminateTags: List<String>, stateFilter: CardStateFilter) {
-        val sb = StringBuilder(stateFilter.toSearch)
-        val arr: MutableList<String?> = ArrayList(selectedTags.size)
-        if (selectedTags.isNotEmpty()) {
-            for (tag in selectedTags) {
-                arr.add("tag:\"$tag\"")
+    override fun onSelectedTags(selectedTags: List<String>, indeterminateTags: List<String>, customStudyExtra: CustomStudyCramResponse) {
+        Timber.i("Custom study: ${selectedTags.count()} tag(s); filter = $customStudyExtra")
+
+        launchCatchingTask {
+            createCustomStudySession {
+                cram = cram {
+                    tagsToInclude.addAll(selectedTags)
+                    kind = customStudyExtra.kind
+                    cardLimit = customStudyExtra.cardLimit
+                }
             }
-            sb.append("(").append(arr.joinToString(" or ")).append(")")
         }
-        createTagsCustomStudySession(
-            arrayOf(
-                sb.toString(),
-                Consts.DYN_MAX_SIZE,
-                Consts.DYN_RANDOM
-            )
-        )
     }
 
     /**
@@ -349,7 +312,7 @@ class CustomStudyDialog(
      */
     private fun getListIds(): List<ContextMenuOption> {
         // Standard context menu
-        return mutableListOf(STUDY_FORGOT, STUDY_AHEAD, STUDY_PREVIEW, STUDY_TAGS).apply {
+        return mutableListOf(STUDY_FORGOT, STUDY_AHEAD, STUDY_PREVIEW, STUDY_CARD_STATE_OR_TAGS).apply {
             if (defaults.extendReview.isUsable) {
                 this.add(0, STUDY_REV)
             }
@@ -360,6 +323,32 @@ class CustomStudyDialog(
         }
     }
 
+    private suspend fun extendLimits(block: CustomStudyRequestKt.Dsl.() -> Unit) {
+        try {
+            val customStudyRequest = customStudyRequest {
+                deckId = dialogDeckId
+                block(this)
+            }
+            undoableOp { collection.sched.customStudy(customStudyRequest) }
+            customStudyListener?.onExtendStudyLimits()
+        } finally {
+            requireActivity().dismissAllDialogFragments()
+        }
+    }
+
+    private suspend fun createCustomStudySession(block: CustomStudyRequestKt.Dsl.() -> Unit) {
+        try {
+            val customStudyRequest = customStudyRequest {
+                deckId = dialogDeckId
+                block(this)
+            }
+            undoableOp { collection.sched.customStudy(customStudyRequest) }
+            customStudyListener?.onCreateCustomStudySession()
+        } finally {
+            requireActivity().dismissAllDialogFragments()
+        }
+    }
+
     /** Line 1 of the number entry dialog */
     private val text1: String get() = when (selectedSubDialog) {
         STUDY_NEW -> defaults.newQueueAvailable()
@@ -391,66 +380,6 @@ class CustomStudyDialog(
             }
         }
 
-    /**
-     * Create a custom study session
-     * @param terms search terms
-     */
-    private fun createTagsCustomStudySession(terms: Array<Any>) {
-        val dyn: Deck
-
-        val decks = collection.decks
-        val deckToStudyName = decks.name(dialogDeckId)
-        val customStudyDeck = resources.getString(R.string.custom_study_deck_name)
-        val cur = decks.byName(customStudyDeck)
-        if (cur != null) {
-            Timber.i("Found deck: '%s'", customStudyDeck)
-            if (cur.isNormal) {
-                Timber.w("Deck: '%s' was non-dynamic", customStudyDeck)
-                showThemedToast(requireContext(), getString(R.string.custom_study_deck_exists), true)
-                return
-            } else {
-                Timber.i("Emptying dynamic deck '%s' for custom study", customStudyDeck)
-                // safe to empty
-                collection.sched.emptyDyn(cur.getLong("id"))
-                // reuse; don't delete as it may have children
-                dyn = cur
-                decks.select(cur.getLong("id"))
-            }
-        } else {
-            Timber.i("Creating Dynamic Deck '%s' for custom study", customStudyDeck)
-            dyn = try {
-                decks.get(decks.newFiltered(customStudyDeck))!!
-            } catch (ex: BackendDeckIsFilteredException) {
-                showThemedToast(requireActivity(), ex.localizedMessage ?: ex.message ?: "", true)
-                return
-            }
-        }
-        // and then set various options
-        dyn.put("delays", JSONObject.NULL)
-        val ar = dyn.getJSONArray("terms")
-        ar.getJSONArray(0).put(0, "deck:\"" + deckToStudyName + "\" " + terms[0])
-        ar.getJSONArray(0).put(1, terms[1])
-        @DynPriority val priority = terms[2] as Int
-        ar.getJSONArray(0).put(2, priority)
-        dyn.put("resched", true)
-        // Rebuild the filtered deck
-        Timber.i("Rebuilding Custom Study Deck")
-        // PERF: Should be in background
-        collection.decks.save(dyn)
-        // launch this in the activity scope, rather than the fragment scope
-        requireActivity().launchCatchingTask { rebuildDynamicDeck() }
-        // Hide the dialogs (required due to a DeckPicker issue)
-        requireActivity().dismissAllDialogFragments()
-    }
-
-    private suspend fun rebuildDynamicDeck() {
-        Timber.d("rebuildDynamicDeck()")
-        withProgress {
-            withCol { sched.rebuildDyn(decks.selected()) }
-            customStudyListener?.onCreateCustomStudySession()
-        }
-    }
-
     /**
      * Possible context menu options that could be shown in the custom study dialog.
      */
@@ -461,7 +390,7 @@ class CustomStudyDialog(
         STUDY_FORGOT({ TR.customStudyReviewForgottenCards() }),
         STUDY_AHEAD({ TR.customStudyReviewAhead() }),
         STUDY_PREVIEW({ TR.customStudyPreviewNewCards() }),
-        STUDY_TAGS({ getString(R.string.custom_study_limit_tags) })
+        STUDY_CARD_STATE_OR_TAGS({ TR.customStudyStudyByCardStateOrTag() })
     }
 
     /**
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
index b0a40522386c..1b63b0617731 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
@@ -14,7 +14,10 @@ import android.view.LayoutInflater
 import android.view.MenuItem
 import android.view.View
 import android.view.WindowManager
+import android.widget.Button
 import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.RadioButton
 import android.widget.RadioGroup
 import android.widget.TextView
 import androidx.annotation.VisibleForTesting
@@ -25,13 +28,18 @@ import androidx.appcompat.widget.Toolbar
 import androidx.core.content.ContextCompat
 import androidx.core.os.BundleCompat
 import androidx.core.os.bundleOf
+import androidx.core.view.isVisible
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
+import com.ichi2.anki.CollectionManager.TR
 import com.ichi2.anki.OnContextAndLongClickListener
 import com.ichi2.anki.R
 import com.ichi2.anki.analytics.AnalyticsDialogFragment
+import com.ichi2.anki.dialogs.IntegerDialog
+import com.ichi2.anki.dialogs.customstudy.CustomStudyCramResponse
 import com.ichi2.anki.model.CardStateFilter
 import com.ichi2.anki.snackbar.showSnackbar
+import com.ichi2.anki.utils.ext.showDialogFragment
 import com.ichi2.annotations.NeedsTest
 import com.ichi2.ui.AccessibleSearchView
 import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown
@@ -85,6 +93,15 @@ class TagsDialog : AnalyticsDialogFragment {
     private val listener: TagsDialogListener?
 
     private lateinit var selectedOption: CardStateFilter
+    private var customStudyCardLimit: Int = 100
+        set(value) {
+            field = value
+            // WARN: Anki concats with an input box, leading to incorrect plural:
+            // 'Select [1 -+] cards from this deck'
+            this.dialog
+                ?.findViewById<TextView>(R.id.custom_study_card_count)
+                ?.text = "${TR.customStudySelect()} $value ${TR.customStudyCardsFromTheDeck()}"
+        }
 
     /**
      * Constructs a new [TagsDialog] that will communicate the results using the provided listener.
@@ -102,6 +119,11 @@ class TagsDialog : AnalyticsDialogFragment {
         listener = null
     }
 
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        outState.putInt("customStudyCardLimit", customStudyCardLimit)
+    }
+
     /**
      * @param type the type of dialog @see [DialogType]
      * @param checkedTags tags of the note
@@ -138,6 +160,9 @@ class TagsDialog : AnalyticsDialogFragment {
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
+        if (savedInstanceState?.containsKey("customStudyCardLimit") == true) {
+            customStudyCardLimit = savedInstanceState.getInt("customStudyCardLimit")
+        }
         resizeWhenSoftInputShown(requireActivity().window)
 
         val tagsFile = requireNotNull(
@@ -179,16 +204,12 @@ class TagsDialog : AnalyticsDialogFragment {
         if (tags!!.isEmpty) {
             noTagsTextView?.visibility = View.VISIBLE
         }
-        val optionsGroup = tagsDialogView.findViewById<RadioGroup>(R.id.tags_dialog_options_radiogroup)
-        for (i in 0 until optionsGroup.childCount) {
-            optionsGroup.getChildAt(i).id = i
-        }
-        optionsGroup.check(0)
-        selectedOption = radioButtonIdToCardState(optionsGroup.checkedRadioButtonId)
-        optionsGroup.setOnCheckedChangeListener { _: RadioGroup?, checkedId: Int -> selectedOption = radioButtonIdToCardState(checkedId) }
+
+        // setup options group (only visible for custom study)
+        setupCustomStudyOptions(tagsDialogView)
+
         if (type == DialogType.EDIT_TAGS) {
             dialogTitle = resources.getString(R.string.card_details_tags)
-            optionsGroup.visibility = View.GONE
             positiveText = getString(R.string.dialog_ok)
             tagsArrayAdapter!!.tagContextAndLongClickListener =
                 OnContextAndLongClickListener { v ->
@@ -203,10 +224,14 @@ class TagsDialog : AnalyticsDialogFragment {
         adjustToolbar(tagsDialogView)
         dialog = AlertDialog.Builder(requireActivity())
             .positiveButton(text = positiveText!!) {
+                val response = CustomStudyCramResponse(
+                    kind = selectedOption.cramKind,
+                    cardLimit = customStudyCardLimit
+                )
                 tagsDialogListener.onSelectedTags(
                     tags!!.copyOfCheckedTagList(),
                     tags!!.copyOfIndeterminateTagList(),
-                    selectedOption
+                    response
                 )
             }
             .negativeButton(R.string.dialog_cancel)
@@ -217,21 +242,57 @@ class TagsDialog : AnalyticsDialogFragment {
         return dialog
     }
 
+    // TODO: This should probably be a separate dialog
+    private fun setupCustomStudyOptions(view: View) {
+        if (type != DialogType.CUSTOM_STUDY_TAGS) {
+            view.findViewById<View>(R.id.custom_study_tag_settings).isVisible = false
+            return
+        }
+
+        // 'cards to study' - Anki default is 100
+        view.findViewById<Button>(R.id.custom_study_set_card_count).setOnClickListener {
+            // TODO: Could be a better dialog
+            var dialog = IntegerDialog().apply {
+                setArgs(
+                    title = "",
+                    prompt = null,
+                    defaultValue = customStudyCardLimit.toString(),
+                    digits = 5
+                )
+                setCallbackRunnable { customStudyCardLimit = it }
+            }
+            showDialogFragment(dialog)
+        }
+
+        // setup radio buttons
+        val optionsGroup = view.findViewById<RadioGroup>(R.id.tags_dialog_options_radiogroup)
+        for (filter in CardStateFilter.entries) {
+            RadioButton(optionsGroup.context).apply {
+                id = filter.order
+                text = filter.getDescription()
+                layoutParams = LinearLayout.LayoutParams(
+                    LinearLayout.LayoutParams.MATCH_PARENT, // full width to make more clickable
+                    LinearLayout.LayoutParams.WRAP_CONTENT
+                )
+                optionsGroup.addView(this)
+            }
+        }
+        optionsGroup.check(0)
+        selectedOption = radioButtonIdToCardState(optionsGroup.checkedRadioButtonId)
+        optionsGroup.setOnCheckedChangeListener { _: RadioGroup?, checkedId: Int -> selectedOption = radioButtonIdToCardState(checkedId) }
+        optionsGroup.isVisible = true
+    }
+
     override fun onResume() {
         super.onResume()
         dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
+
+        // Update the UI now we have a view we can access
+        this.customStudyCardLimit = customStudyCardLimit
     }
 
     private fun radioButtonIdToCardState(id: Int) =
-        when (id) {
-            0 -> CardStateFilter.ALL_CARDS
-            1 -> CardStateFilter.NEW
-            2 -> CardStateFilter.DUE
-            else -> {
-                Timber.w("unexpected value: %d", id)
-                CardStateFilter.ALL_CARDS
-            }
-        }
+        CardStateFilter.fromOrder(id) ?: CardStateFilter.ALL_CARDS
 
     private fun adjustToolbar(tagsDialogView: View) {
         val toolbar: Toolbar = tagsDialogView.findViewById(R.id.tags_dialog_toolbar)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt
index f37c0f83ebc8..a746e74fb44b 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt
@@ -18,7 +18,7 @@ package com.ichi2.anki.dialogs.tags
 import android.os.Bundle
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.FragmentManager
-import com.ichi2.anki.model.CardStateFilter
+import com.ichi2.anki.dialogs.customstudy.CustomStudyCramResponse
 import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat
 import com.ichi2.utils.KotlinCleanup
 import java.util.ArrayList
@@ -33,9 +33,9 @@ interface TagsDialogListener {
      * determining if tags in this list is checked or not is done by looking at the list of
      * previous tags. if the tag is found in both previous and indeterminate, it should be kept
      * otherwise it should be removed @see [com.ichi2.utils.TagsUtil.getUpdatedTags]
-     * @param stateFilter selection radio option, should be ignored if not expected
+     * @param customStudyExtra selection radio option, should be ignored if not expected
      */
-    fun onSelectedTags(selectedTags: List<String>, indeterminateTags: List<String>, stateFilter: CardStateFilter)
+    fun onSelectedTags(selectedTags: List<String>, indeterminateTags: List<String>, customStudyExtra: CustomStudyCramResponse)
     fun <F> F.registerFragmentResultReceiver() where F : Fragment, F : TagsDialogListener {
         parentFragmentManager.setFragmentResultListener(
             ON_SELECTED_TAGS_KEY,
@@ -45,18 +45,18 @@ interface TagsDialogListener {
                 bundle.getStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS)!!
             val indeterminateTags: List<String> =
                 bundle.getStringArrayList(ON_SELECTED_TAGS__INDETERMINATE_TAGS)!!
-            val option = bundle.getSerializableCompat<CardStateFilter>(ON_SELECTED_TAGS__OPTION)!!
+            val option = bundle.getSerializableCompat<CustomStudyCramResponse>(ON_SELECTED_TAGS__OPTION)!!
             onSelectedTags(selectedTags, indeterminateTags, option)
         }
     }
 
     companion object {
         fun createFragmentResultSender(fragmentManager: FragmentManager) = object : TagsDialogListener {
-            override fun onSelectedTags(selectedTags: List<String>, indeterminateTags: List<String>, stateFilter: CardStateFilter) {
+            override fun onSelectedTags(selectedTags: List<String>, indeterminateTags: List<String>, customStudyExtra: CustomStudyCramResponse) {
                 val bundle = Bundle().apply {
                     putStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS, ArrayList(selectedTags))
                     putStringArrayList(ON_SELECTED_TAGS__INDETERMINATE_TAGS, ArrayList(indeterminateTags))
-                    putSerializable(ON_SELECTED_TAGS__OPTION, stateFilter)
+                    putSerializable(ON_SELECTED_TAGS__OPTION, customStudyExtra)
                 }
                 fragmentManager.setFragmentResult(ON_SELECTED_TAGS_KEY, bundle)
             }
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/CardStateFilter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/CardStateFilter.kt
index 9f1c84c8fe98..6d78019dca8b 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/model/CardStateFilter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/CardStateFilter.kt
@@ -16,21 +16,30 @@
 
 package com.ichi2.anki.model
 
+import anki.scheduler.CustomStudyRequest.Cram.CramKind
+import com.ichi2.anki.CollectionManager.TR
+
 /**
  * Allows filtering a search by the state of cards
  *
  * @see [anki.search.SearchNode.CardState]
+ *
+ * https://github.com/ankitects/anki/blob/f6a3e98ac3dcb19d54e7fdbba96bf2fa15fc2b3f/rslib/src/scheduler/filtered/custom_study.rs#L246-L277
  */
-enum class CardStateFilter {
-    ALL_CARDS,
-    NEW,
-    DUE
-    ;
+enum class CardStateFilter(val getDescription: () -> String, val order: Int, val cramKind: CramKind) {
+    /** New cards, in order added, reschedule */
+    NEW({ TR.customStudyNewCardsOnly() }, 0, CramKind.CRAM_KIND_NEW),
+
+    /** Due cards (is:due), order by due, reschedule */
+    DUE({ TR.customStudyDueCardsOnly() }, 1, CramKind.CRAM_KIND_DUE),
+
+    /** Anything but new cards, random order, reschedule */
+    REVIEW({ TR.customStudyAllReviewCardsInRandomOrder() }, 2, CramKind.CRAM_KIND_REVIEW),
+
+    /** All cards, random order, no rescheduling */
+    ALL_CARDS({ TR.customStudyAllCardsInRandomOrderDont() }, 3, CramKind.CRAM_KIND_ALL);
 
-    val toSearch: String
-        get() = when (this) {
-            ALL_CARDS -> ""
-            NEW -> "is:new "
-            DUE -> "is:due "
-        }
+    companion object {
+        fun fromOrder(order: Int) = CardStateFilter.entries.firstOrNull { it.order == order }
+    }
 }
diff --git a/AnkiDroid/src/main/res/layout/tags_dialog.xml b/AnkiDroid/src/main/res/layout/tags_dialog.xml
index af27c3e46b76..47a59204f964 100644
--- a/AnkiDroid/src/main/res/layout/tags_dialog.xml
+++ b/AnkiDroid/src/main/res/layout/tags_dialog.xml
@@ -1,6 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
     android:orientation="vertical"
     android:layout_width="match_parent"
     android:layout_height="match_parent">
@@ -16,26 +17,56 @@
         android:visibility="gone"/>
 
 
-    <RadioGroup android:id="@+id/tags_dialog_options_radiogroup"
-        android:layout_width="wrap_content"
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/custom_study_tag_settings"
+        android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:layout_centerHorizontal="true"
-        android:layout_alignParentBottom="true"
-        android:orientation="horizontal">
-        <!-- editing this requires a to map to CardStateFilter inside TagsDialog -->
-        <RadioButton
+        android:layout_marginEnd="4dp"
+        android:layout_alignParentBottom="true">
+
+
+        <RadioGroup android:id="@+id/tags_dialog_options_radiogroup"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:text="@string/tags_dialog_option_all_cards"/>
-        <RadioButton
+            android:layout_centerHorizontal="true"
+            android:paddingStart="16dp"
+            android:paddingEnd="16dp"
+            android:paddingBottom="8dp"
+            android:orientation="vertical"
+            app:layout_constraintBottom_toTopOf="@id/custom_study_card_count">
+            <!-- CardStateFilter is used to add vertical buttons -->
+<!--            <RadioButton-->
+<!--                android:layout_width="wrap_content"-->
+<!--                android:layout_height="wrap_content"-->
+<!--                android:text="test" />-->
+        </RadioGroup>
+
+
+        <TextView
+            android:id="@+id/custom_study_card_count"
             android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:text="@string/tags_dialog_option_new_cards"/>
-        <RadioButton
+            android:layout_height="0dp"
+            android:gravity="center"
+            android:layout_marginStart="16dp"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="@+id/custom_study_set_card_count"
+            app:layout_constraintBottom_toBottomOf="@+id/custom_study_set_card_count"
+            android:paddingStart="8dp"
+            tools:text="Select 100 cards from the deck" />
+
+        <Button
+            android:id="@+id/custom_study_set_card_count"
+            style="@style/Widget.Material3.Button.TextButton"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:text="@string/tags_dialog_option_due_cards"/>
-    </RadioGroup>
+            android:text="EDIT"
+            android:textColor="?attr/colorAccent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintBottom_toBottomOf="parent"
+            />
+
+
+    </androidx.constraintlayout.widget.ConstraintLayout>
 
     <androidx.recyclerview.widget.RecyclerView android:id="@+id/tags_dialog_tags_list"
                                                android:scrollbars="vertical"
diff --git a/AnkiDroid/src/main/res/values/01-core.xml b/AnkiDroid/src/main/res/values/01-core.xml
index ca6e030e65a9..a17bab6b2ec1 100644
--- a/AnkiDroid/src/main/res/values/01-core.xml
+++ b/AnkiDroid/src/main/res/values/01-core.xml
@@ -103,8 +103,6 @@
     <string name="empty_cram_label" maxLength="28">Empty</string>
     <string name="create_subdeck">Create subdeck</string>
     <string name="empty_filtered_deck">Emptying filtered deck…</string>
-    <string name="custom_study_deck_name">Custom study session</string>
-    <string name="custom_study_deck_exists">Rename the existing custom study deck first</string>
     <string name="search_deck" comment="Deck search for selecting it">Deck Search</string>
     <string name="empty_deck">This deck is empty</string>
     <string name="search_for_download_deck" comment="Deck search value for downloading deck" maxLength="28">Deck Search</string>
diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml
index fba8a26862f4..516d610721ef 100644
--- a/AnkiDroid/src/main/res/values/02-strings.xml
+++ b/AnkiDroid/src/main/res/values/02-strings.xml
@@ -62,7 +62,6 @@
     <string name="filter_by_flags" maxLength="41">Flags</string>
 
     <!-- Custom study options -->
-    <string name="custom_study_limit_tags">Limit to particular tags</string>
     <plurals name="studyoptions_buried_count">
         <item quantity="other">+%d buried</item>
     </plurals>
diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml
index 8fd409a5a4c6..603f1d1921a4 100644
--- a/AnkiDroid/src/main/res/values/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values/03-dialogs.xml
@@ -108,11 +108,6 @@
         <item quantity="other">%d files deleted</item>
     </plurals>
 
-    <!-- Tags Dialog Options -->
-    <string name="tags_dialog_option_all_cards">All cards</string>
-    <string name="tags_dialog_option_new_cards">New</string>
-    <string name="tags_dialog_option_due_cards" comment="Name of cards that are already been reviewed in the past, and that should be reviewed again">Due</string>
-
     <!-- Empty cards -->
     <string name="emtpy_cards_finding">Finding empty cards…</string>
     <string name="empty_cards_count">Cards to delete: %d</string>

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions