Open
Description
opened on Dec 10, 2024
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
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