Skip to content

Commit 50b9de0

Browse files
committed
feat: improve Study by card state or tag dialog
Assisted-by: Gemini 3 Flash
1 parent 424951b commit 50b9de0

9 files changed

Lines changed: 281 additions & 74 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt

Lines changed: 192 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import anki.scheduler.CustomStudyRequest.Cram.CramKind
4747
import anki.scheduler.copy
4848
import anki.scheduler.customStudyRequest
4949
import anki.search.SearchNode
50+
import anki.search.searchNode
5051
import com.ichi2.anki.CollectionManager.TR
5152
import com.ichi2.anki.CollectionManager.withCol
5253
import com.ichi2.anki.R
@@ -147,11 +148,128 @@ class CustomStudyDialog : AnalyticsDialogFragment() {
147148
parentFragmentManager.setFragmentResultListener(ON_SELECTED_TAGS_KEY, this) { _, bundle ->
148149
val tagsToInclude = bundle.getStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS) ?: emptyList<String>()
149150
val option = selectedSubDialog ?: return@setFragmentResultListener
150-
val selectedCardStateIndex = viewModel.selectedCardStateIndex
151-
if (selectedCardStateIndex == AdapterView.INVALID_POSITION) return@setFragmentResultListener
152-
val kind = CustomStudyCardState.entries[selectedCardStateIndex].kind
153-
val cardsAmount = userInputValue ?: 100 // the default value
154-
launchCustomStudy(option, cardsAmount, kind, tagsToInclude, emptyList())
151+
if (option == ContextMenuOption.STUDY_TAGS) {
152+
viewModel.selectedTags = tagsToInclude
153+
updateTags()
154+
(dialog as? AlertDialog)?.let { validateSelection(it) }
155+
}
156+
}
157+
}
158+
159+
/**
160+
* Updates the text of the tag selection button to reflect the currently selected tags.
161+
* If no tags are selected, it displays the default "Tags:" prefix.
162+
*/
163+
private fun updateTags() {
164+
if (!::binding.isInitialized) return
165+
val tags = viewModel.selectedTags
166+
val prefix = getString(R.string.card_details_tags)
167+
binding.tagsSelectionButton.text =
168+
if (tags.isEmpty()) {
169+
"$prefix: "
170+
} else {
171+
"$prefix: ${tags.joinToString(", ")}"
172+
}
173+
}
174+
175+
/**
176+
* Fetches notes from the current deck and launches the [TagsDialog] for tag selection.
177+
* The dialog is only shown if there are notes with tags available to filter.
178+
*/
179+
private fun showTagSelectionDialog() {
180+
launchCatchingTask {
181+
val selectedState = CustomStudyCardState.entries[viewModel.selectedCardStateIndex]
182+
val currentDeckName = withCol { decks.name(viewModel.deckId) }
183+
184+
// Find all notes in the deck that have at least one tag
185+
val nids =
186+
withCol {
187+
val searchNodes =
188+
buildList {
189+
add(SearchNode.newBuilder().setDeck(currentDeckName).build())
190+
add(SearchNode.newBuilder().setNegated(SearchNode.newBuilder().setTag("none").build()).build())
191+
}
192+
findNotes(buildSearchString(searchNodes))
193+
}
194+
195+
if (isAdded && nids.isNotEmpty()) {
196+
val stateQuery =
197+
withCol {
198+
val searchNodes =
199+
buildList {
200+
add(SearchNode.newBuilder().setDeck(currentDeckName).build())
201+
add(selectedState.toSearchNode())
202+
}
203+
buildSearchString(searchNodes)
204+
}
205+
TagsDialog()
206+
.withArguments(
207+
requireContext(),
208+
TagsDialog.DialogType.CUSTOM_STUDY,
209+
nids,
210+
stateQuery,
211+
ArrayList(viewModel.selectedTags),
212+
).show(parentFragmentManager, "TagsDialog")
213+
}
214+
}
215+
}
216+
217+
/**
218+
* Validates the current selection and updates the UI state accordingly.
219+
* This includes checking for available cards in the selected state and
220+
* filtering out any previously selected tags that are no longer valid for the new state.
221+
*/
222+
private fun validateSelection(dialog: AlertDialog) {
223+
val option = selectedSubDialog ?: return
224+
if (option != ContextMenuOption.STUDY_TAGS) return
225+
val selectedState = CustomStudyCardState.entries[viewModel.selectedCardStateIndex]
226+
launchCatchingTask {
227+
val stateQuery =
228+
withCol {
229+
val currentDeckName = decks.name(viewModel.deckId)
230+
buildSearchString(
231+
listOf(
232+
SearchNode.newBuilder().setDeck(currentDeckName).build(),
233+
selectedState.toSearchNode(),
234+
),
235+
)
236+
}
237+
val cardCount = withCol { findNotes(stateQuery).size }
238+
if (cardCount == 0) {
239+
binding.cardsStateSelectorLayout.error = TR.customStudyNoCardsMatchedTheCriteriaYou()
240+
dialog.positiveButton.isEnabled = false
241+
242+
binding.tagsSelectionButton.isEnabled = false
243+
viewModel.selectedTags = emptyList()
244+
updateTags()
245+
} else {
246+
binding.cardsStateSelectorLayout.error = null
247+
dialog.positiveButton.isEnabled = userInputValue != null && userInputValue != 0
248+
249+
// We only retain tags that actually yield cards within the newly selected card state.
250+
val currentSelected = viewModel.selectedTags
251+
val stillValidTags =
252+
if (currentSelected.isEmpty()) {
253+
emptyList()
254+
} else {
255+
withCol {
256+
currentSelected.filter { tag ->
257+
val checkQuery = "$stateQuery tag:$tag"
258+
findNotes(checkQuery).isNotEmpty()
259+
}
260+
}
261+
}
262+
263+
// Determine if the tag selection button should be enabled based on tag availability in this state
264+
val hasTags =
265+
withCol {
266+
val tagsQuery = "$stateQuery -tag:none"
267+
findNotes(tagsQuery).isNotEmpty()
268+
}
269+
viewModel.selectedTags = stillValidTags
270+
binding.tagsSelectionButton.isEnabled = hasTags
271+
updateTags()
272+
}
155273
}
156274
}
157275

@@ -297,9 +415,25 @@ class CustomStudyDialog : AnalyticsDialogFragment() {
297415
binding = FragmentCustomStudyBinding.inflate(requireActivity().layoutInflater)
298416

299417
binding.detailsText1.text = text1
418+
binding.detailsText1.isVisible = text1.isNotEmpty()
300419
binding.detailsText2.text = text2
420+
binding.detailsText2.isVisible = text2.isNotEmpty()
301421

422+
binding.detailsEditText2Layout.hint =
423+
when (contextMenuOption) {
424+
STUDY_TAGS -> getString(R.string.custom_study_card_limit_hint)
425+
STUDY_FORGOT, STUDY_AHEAD, STUDY_PREVIEW -> getString(R.string.custom_study_day_limit_hint)
426+
EXTEND_NEW, EXTEND_REV -> ""
427+
}
302428
binding.cardsStateSelectorLayout.isVisible = contextMenuOption == STUDY_TAGS
429+
binding.tagsSelectionButton.isVisible = contextMenuOption == STUDY_TAGS
430+
if (contextMenuOption == STUDY_TAGS) {
431+
updateTags()
432+
// Open the tag selection dialog which is pre-filtered by the current card state
433+
binding.tagsSelectionButton.setOnClickListener {
434+
showTagSelectionDialog()
435+
}
436+
}
303437
binding.cardsStateSelector.apply {
304438
fun setAdapterAndSelection(
305439
entries: List<String>,
@@ -319,6 +453,7 @@ class CustomStudyDialog : AnalyticsDialogFragment() {
319453
AdapterView.OnItemClickListener { _, _, position, _ ->
320454
viewModel.selectedCardStateIndex = position
321455
setAdapterAndSelection(cardStates, viewModel.selectedCardStateIndex)
456+
validateSelection(dialog as AlertDialog)
322457
}
323458
// set the first item as automatically selected if we don't already have a valid
324459
// position stored in the ViewModel
@@ -332,14 +467,19 @@ class CustomStudyDialog : AnalyticsDialogFragment() {
332467
// Give EditText focus and show keyboard
333468
setSelectAllOnFocus(true)
334469
requestFocus()
470+
// Ensure the text is highlighted once the dialog and keyboard are ready
471+
post {
472+
selectAll()
473+
}
474+
filters = arrayOf(android.text.InputFilter.LengthFilter(5))
335475
// a user may enter a negative value when extending limits
336476
if (contextMenuOption == EXTEND_NEW || contextMenuOption == EXTEND_REV) {
337477
inputType = EditorInfo.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_FLAG_SIGNED
338478
}
339479
}
340480
val positiveBtnLabel =
341481
if (contextMenuOption == STUDY_TAGS) {
342-
TR.customStudyChooseTags().toSentenceCase(R.string.sentence_choose_tags)
482+
getString(R.string.dialog_positive_create)
343483
} else {
344484
getString(R.string.dialog_ok)
345485
}
@@ -351,6 +491,7 @@ class CustomStudyDialog : AnalyticsDialogFragment() {
351491
val dialog =
352492
AlertDialog
353493
.Builder(requireActivity())
494+
.title(text = contextMenuOption.getTitle(resources))
354495
.customView(
355496
view = binding.root,
356497
paddingStart = horizontalPadding,
@@ -385,45 +526,44 @@ class CustomStudyDialog : AnalyticsDialogFragment() {
385526
// we come back we wouldn't be able to trigger again TagLimitFragment
386527
allowSubmit = true
387528
launchCatchingTask {
388-
val nids =
389-
withCol {
390-
val currentDeckName = decks.name(viewModel.deckId)
391-
// this allows us to skip the tag selection dialog entirely
392-
// if there are no tags available to choose from in this deck
393-
val searchNodes =
394-
buildList {
395-
add(SearchNode.newBuilder().setDeck(currentDeckName).build())
396-
add(
397-
SearchNode
398-
.newBuilder()
399-
.setNegated(SearchNode.newBuilder().setTag("none").build())
400-
.build(),
401-
)
402-
}
403-
findNotes(buildSearchString(searchNodes))
404-
}
405-
// skip tag selection if there's no tags to select
406-
if (nids.isEmpty()) {
407-
launchCustomStudy(contextMenuOption, n)
408-
return@launchCatchingTask
409-
}
410-
if (isAdded) {
411-
TagsDialog()
412-
.withArguments(
413-
requireContext(),
414-
TagsDialog.DialogType.CUSTOM_STUDY,
415-
nids,
416-
).show(parentFragmentManager, "TagsDialog")
529+
val selectedCardStateIndex = viewModel.selectedCardStateIndex
530+
if (selectedCardStateIndex != AdapterView.INVALID_POSITION) {
531+
val kind = CustomStudyCardState.entries[selectedCardStateIndex].kind
532+
launchCustomStudy(
533+
contextMenuOption,
534+
n,
535+
kind,
536+
viewModel.selectedTags,
537+
emptyList(),
538+
)
417539
}
418540
}
419-
return@setOnClickListener
541+
} else {
542+
launchCustomStudy(contextMenuOption, n)
420543
}
421-
launchCustomStudy(contextMenuOption, n)
422544
}
545+
validateSelection(dialog)
423546
}
424547

425-
binding.detailsEditText2.doAfterTextChanged {
426-
dialog.positiveButton.isEnabled = userInputValue != null && userInputValue != 0
548+
binding.detailsEditText2.doAfterTextChanged { text ->
549+
val input = text?.toString() ?: ""
550+
if (contextMenuOption == STUDY_TAGS || contextMenuOption == STUDY_FORGOT ||
551+
contextMenuOption == STUDY_AHEAD || contextMenuOption == STUDY_PREVIEW
552+
) {
553+
if (input.length > 1 && input.startsWith("0")) {
554+
// Prevent leading zeros in the number field
555+
val sanitized = input.trimStart('0')
556+
binding.detailsEditText2.setText(sanitized)
557+
binding.detailsEditText2.setSelection(sanitized.length)
558+
return@doAfterTextChanged
559+
}
560+
}
561+
val hasSelectionError = binding.cardsStateSelectorLayout.error != null
562+
if (hasSelectionError) {
563+
dialog.positiveButton.isEnabled = false
564+
} else {
565+
dialog.positiveButton.isEnabled = userInputValue != null && userInputValue != 0
566+
}
427567
}
428568

429569
// Show soft keyboard
@@ -527,7 +667,7 @@ class CustomStudyDialog : AnalyticsDialogFragment() {
527667
STUDY_FORGOT -> res.getString(R.string.custom_study_forgotten)
528668
STUDY_AHEAD -> res.getString(R.string.custom_study_ahead)
529669
STUDY_PREVIEW -> res.getString(R.string.custom_study_preview)
530-
STUDY_TAGS -> res.getString(R.string.custom_study_tags)
670+
STUDY_TAGS -> ""
531671
null -> ""
532672
}
533673
}
@@ -620,6 +760,18 @@ class CustomStudyDialog : AnalyticsDialogFragment() {
620760
DueCardsOnly({ TR.customStudyDueCardsOnly() }, CramKind.CRAM_KIND_DUE),
621761
ReviewCardsRandom({ TR.customStudyAllReviewCardsInRandomOrder() }, CramKind.CRAM_KIND_REVIEW),
622762
AllCardsRandom({ TR.customStudyAllCardsInRandomOrderDont() }, CramKind.CRAM_KIND_ALL),
763+
;
764+
765+
/**
766+
* Converts the [CustomStudyCardState] into a [SearchNode] used for card filtering.
767+
*/
768+
fun toSearchNode(): SearchNode =
769+
when (this) {
770+
NewCardsOnly -> searchNode { cardState = SearchNode.CardState.CARD_STATE_NEW }
771+
DueCardsOnly -> searchNode { cardState = SearchNode.CardState.CARD_STATE_DUE }
772+
ReviewCardsRandom -> searchNode { cardState = SearchNode.CardState.CARD_STATE_REVIEW }
773+
AllCardsRandom -> searchNode { parsableText = "" }
774+
}
623775
}
624776

625777
/**

AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyViewModel.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,19 @@ class CustomStudyViewModel(
4242
val deckId: DeckId
4343
get() = savedStateHandle.get<DeckId>(KEY_DID) ?: error("Deck id was not provided!")
4444

45+
/** The list of tags selected by the user for the custom study session. */
46+
var selectedTags: List<String>
47+
get() = savedStateHandle.get<List<String>>(KEY_SELECTED_TAGS) ?: emptyList()
48+
set(value) {
49+
savedStateHandle[KEY_SELECTED_TAGS] = value
50+
}
51+
4552
companion object {
4653
/**
4754
* Required key for a [DeckId] which [CustomStudyDialog] expects to receive as an argument.
4855
*/
4956
const val KEY_DID = "key_did"
5057
private const val KEY_CARDS_SELECTION_INDEX = "key_cards_selection_index"
58+
private const val KEY_SELECTED_TAGS = "key_selected_tags"
5159
}
5260
}

AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,14 @@ class TagsDialog : AnalyticsDialogFragment {
105105
}
106106
val type = BundleCompat.getParcelable(requireArguments(), ARG_DIALOG_TYPE, DialogType::class.java)
107107
val isCustomStudying = type != null && type == DialogType.CUSTOM_STUDY
108+
val filterQuery = requireArguments().getString(ARG_FILTER_QUERY)
108109
viewModelFactory {
109110
initializer {
110111
TagsDialogViewModel(
111112
noteIds = noteIds,
112113
checkedTags = checkedTags,
113114
isCustomStudying = isCustomStudying,
115+
filterQuery = filterQuery,
114116
)
115117
}
116118
}
@@ -142,6 +144,7 @@ class TagsDialog : AnalyticsDialogFragment {
142144
context: Context,
143145
type: DialogType,
144146
noteIds: List<NoteId> = emptyList(),
147+
filterQuery: String? = null,
145148
checkedTags: ArrayList<String> = arrayListOf(),
146149
): TagsDialog {
147150
// TODO: checkedTags is unbounded and could exceed the bundle size
@@ -150,6 +153,7 @@ class TagsDialog : AnalyticsDialogFragment {
150153
ARG_TAGS_FILE to file,
151154
ARG_DIALOG_TYPE to type,
152155
ARG_CHECKED_TAGS to checkedTags,
156+
ARG_FILTER_QUERY to filterQuery,
153157
)
154158
return this
155159
}
@@ -180,10 +184,11 @@ class TagsDialog : AnalyticsDialogFragment {
180184
binding = DialogTagsBinding.inflate(layoutInflater)
181185

182186
val positiveText =
183-
if (type == DialogType.EDIT_TAGS) {
184-
getString(R.string.dialog_confirm)
185-
} else {
186-
getString(R.string.select)
187+
when (type) {
188+
DialogType.EDIT_TAGS,
189+
DialogType.CUSTOM_STUDY,
190+
-> getString(R.string.dialog_confirm)
191+
else -> getString(R.string.select)
187192
}
188193

189194
val tagsListLayout: RecyclerView.LayoutManager = LinearLayoutManager(requireContext())
@@ -478,6 +483,7 @@ class TagsDialog : AnalyticsDialogFragment {
478483
const val ARG_TAGS_FILE = "tagsFile"
479484
private const val ARG_DIALOG_TYPE = "dialogType"
480485
private const val ARG_CHECKED_TAGS = "checkedTags"
486+
private const val ARG_FILTER_QUERY = "filterQuery"
481487

482488
/**
483489
* The filter that constrains the inputted tag.

0 commit comments

Comments
 (0)