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 5c317d7efd07..3a0351f27a57 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 @@ -22,6 +22,9 @@ import android.app.Dialog import android.content.res.Resources import android.os.Bundle import android.os.Parcelable +import android.text.Editable +import android.text.InputFilter +import android.text.Spanned import android.util.TypedValue import android.view.WindowManager import android.view.inputmethod.EditorInfo @@ -88,6 +91,7 @@ import com.ichi2.utils.setPaddingRelative import com.ichi2.utils.textAsIntOrNull import com.ichi2.utils.title import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.parcelize.Parcelize import net.ankiweb.rsdroid.BackendException @@ -331,14 +335,28 @@ class CustomStudyDialog : AnalyticsDialogFragment() { // Give EditText focus and show keyboard setSelectAllOnFocus(true) requestFocus() + inputType = EditorInfo.TYPE_CLASS_NUMBER // a user may enter a negative value when extending limits if (contextMenuOption == EXTEND_NEW || contextMenuOption == EXTEND_REV) { inputType = EditorInfo.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_FLAG_SIGNED } + if (contextMenuOption == STUDY_AHEAD) { + // UI safeguard: prevent excessively long numeric input + filters = arrayOf(InputFilter.LengthFilter(5), NoLeadingZeroFilter()) + val initialValue = defaultValue.toIntOrNull() ?: 1 + binding.detailsEditText2Layout.suffixText = + resources.getQuantityString( + R.plurals.set_due_date_label_suffix, + initialValue, + initialValue, + ) + } } val positiveBtnLabel = if (contextMenuOption == STUDY_TAGS) { TR.customStudyChooseTags().toSentenceCase(R.string.sentence_choose_tags) + } else if (contextMenuOption == STUDY_AHEAD) { + getString(R.string.dialog_positive_create) } else { getString(R.string.dialog_ok) } @@ -423,6 +441,11 @@ class CustomStudyDialog : AnalyticsDialogFragment() { binding.detailsEditText2.doAfterTextChanged { dialog.positiveButton.isEnabled = userInputValue != null && userInputValue != 0 + val value = it?.toString()?.toIntOrNull() + + // Prevent invalid inputs like leading zeros (e.g. "01") by normalizing the value. + if (replaceZeroWithNextNumber(it)) return@doAfterTextChanged + studyAheadCase(contextMenuOption, dialog, value) } // Show soft keyboard @@ -430,6 +453,87 @@ class CustomStudyDialog : AnalyticsDialogFragment() { return dialog } + private fun studyAheadCase( + contextMenuOption: ContextMenuOption, + dialog: AlertDialog, + value: Int?, + ) { + if (contextMenuOption != STUDY_AHEAD) return + + if (userInputValue == null) { + binding.detailsEditText2Layout.error = null + dialog.positiveButton.isEnabled = false + return + } + + if (userInputValue == 0) { + binding.detailsEditText2Layout.error = + getString(R.string.custom_study_ahead_prevent_leading_zeros) + dialog.positiveButton.isEnabled = false + return + } + + val safeValue = value ?: return + + binding.detailsEditText2Layout.suffixText = + resources.getQuantityString( + R.plurals.set_due_date_label_suffix, + safeValue, + safeValue, + ) + + val currentInput = userInputValue + lifecycleScope.launch { + val hasCards = hasMatchingCards(contextMenuOption, userInputValue) + + if (currentInput != userInputValue) return@launch + + if (hasCards) { + binding.detailsEditText2Layout.error = null + dialog.positiveButton.isEnabled = true + } else { + binding.detailsEditText2Layout.error = + TR.customStudyNoCardsMatchedTheCriteriaYou() + dialog.positiveButton.isEnabled = false + } + } + } + + private fun replaceZeroWithNextNumber(editable: Editable?): Boolean { + val text = editable?.toString() ?: return true + + if (text.length > 1 && text.startsWith("0")) { + val newText = text.trimStart('0') + + val finalText = newText.ifEmpty { "0" } + + binding.detailsEditText2.setText(finalText) + binding.detailsEditText2.setSelection(finalText.length) + return true + } + return false + } + + @SuppressLint("CheckResult") + private suspend fun hasMatchingCards( + option: ContextMenuOption, + input: Int?, + ): Boolean = + try { + withCol { + val currentDeckName = decks.name(viewModel.deckId) + val query = + when (option) { + STUDY_AHEAD -> "deck:\"$currentDeckName\" prop:due<=$input" + else -> "deck:\"$currentDeckName\"" + } + findCards(query).isNotEmpty() + } + } catch (e: Exception) { + Timber.e(e) + true + } + // TODO cram kind and the included/excluded tags lists are only relevant for STUDY_TAGS and // should be included in the option to not leak in the method's api private suspend fun customStudy( @@ -508,8 +612,8 @@ class CustomStudyDialog : AnalyticsDialogFragment() { when (selectedSubDialog) { EXTEND_NEW -> deferredDefaults.getCompleted().labelForNewQueueAvailable() EXTEND_REV -> deferredDefaults.getCompleted().labelForReviewQueueAvailable() + STUDY_AHEAD -> TR.customStudyReviewAhead() STUDY_FORGOT, - STUDY_AHEAD, STUDY_PREVIEW, STUDY_TAGS, null, @@ -560,6 +664,31 @@ class CustomStudyDialog : AnalyticsDialogFragment() { } } + class NoLeadingZeroFilter : InputFilter { + override fun filter( + source: CharSequence?, + start: Int, + end: Int, + dest: Spanned?, + dstart: Int, + dend: Int, + ): CharSequence? { + val newText = dest?.replaceRange(dstart, dend, source?.subSequence(start, end) ?: "") + + if (dest?.toString() == "0") return null + + return if ( + newText != null && + newText.length > 1 && + newText.startsWith("0") + ) { + "" + } else { + null + } + } + } + /** * Represents actions for managing custom study sessions and extending study limits. * These actions are passed between fragments and activities via the FragmentResult API. diff --git a/AnkiDroid/src/main/res/layout/fragment_custom_study.xml b/AnkiDroid/src/main/res/layout/fragment_custom_study.xml index 679d1d78a8a2..407be4e4116b 100644 --- a/AnkiDroid/src/main/res/layout/fragment_custom_study.xml +++ b/AnkiDroid/src/main/res/layout/fragment_custom_study.xml @@ -37,7 +37,9 @@ android:layout_marginRight="4dip" android:gravity="start" android:text="" - android:textSize="16sp" /> + android:textSize="18sp" + android:textStyle="bold" + /> + android:layout_height="wrap_content" + app:errorEnabled="true" + app:suffixTextAppearance="@style/TextAppearance.MaterialComponents.Body1" + > diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index 33a12e1a3bd9..848ad550d989 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -47,7 +47,8 @@ Increase/decrease (“-3”) today’s new card limit by Increase/decrease (“-3”) today’s review limit by Review cards forgotten in last x days: - Review ahead by x days: + Review cards due in the next: + Value must be greater than 0 Preview new cards added in the last x days: Select x cards from the deck: