@@ -47,6 +47,7 @@ import anki.scheduler.CustomStudyRequest.Cram.CramKind
4747import anki.scheduler.copy
4848import anki.scheduler.customStudyRequest
4949import anki.search.SearchNode
50+ import anki.search.searchNode
5051import com.ichi2.anki.CollectionManager.TR
5152import com.ichi2.anki.CollectionManager.withCol
5253import 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 /* *
0 commit comments