Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,26 @@ import android.content.res.Configuration
import android.os.Bundle
import android.os.Parcelable
import android.text.format.DateFormat
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.core.os.BundleCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import com.google.android.material.button.MaterialButton
import androidx.lifecycle.LiveData
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.textfield.TextInputLayout
import com.google.android.material.color.MaterialColors
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import com.ichi2.anki.ALL_DECKS_ID
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.R
import com.ichi2.anki.databinding.DialogAddEditReminderBinding
import com.ichi2.anki.dialogs.ConfirmationDialog
import com.ichi2.anki.isDefaultDeckEmpty
import com.ichi2.anki.launchCatchingTask
Expand All @@ -53,6 +51,7 @@ import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.startDeckSelection
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.ui.FixedTextView
import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown
import com.ichi2.utils.Permissions
import com.ichi2.utils.customView
Expand Down Expand Up @@ -89,7 +88,7 @@ class AddEditReminderDialog : DialogFragment() {

private val viewModel: AddEditReminderDialogViewModel by viewModels()

private lateinit var contentView: View
private lateinit var binding: DialogAddEditReminderBinding
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lovely stuff!


/**
* The mode of this dialog, retrieved from arguments and set by [getInstance].
Expand All @@ -105,12 +104,12 @@ class AddEditReminderDialog : DialogFragment() {

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
super.onCreateDialog(savedInstanceState)
contentView = layoutInflater.inflate(R.layout.add_edit_reminder_dialog, null)
binding = DialogAddEditReminderBinding.inflate(layoutInflater)
Timber.d("dialog mode: %s", dialogMode.toString())

val dialogBuilder =
AlertDialog.Builder(requireActivity()).apply {
customView(contentView)
customView(binding.root)
positiveButton(R.string.dialog_ok)
neutralButton(R.string.dialog_cancel)

Expand Down Expand Up @@ -138,6 +137,7 @@ class AddEditReminderDialog : DialogFragment() {
setUpAdvancedDropdown()
setUpCardThresholdInput()
setUpOnlyNotifyIfNoReviewsCheckbox()
setUpCountCheckboxes()

// For getting the result of the deck selection sub-dialog from ScheduleReminders
// See ScheduleReminders.onDeckSelected for more information
Expand All @@ -156,17 +156,15 @@ class AddEditReminderDialog : DialogFragment() {
else -> Consts.DEFAULT_DECK_ID
}
viewModel.setDeckSelected(selectedDeckId)
this.dialog?.findViewById<TextView>(R.id.add_edit_reminder_deck_name)?.text =
selectedDeck?.getDisplayName(requireContext())
binding.addEditReminderDeckName.text = selectedDeck?.getDisplayName(requireContext())
}

dialog.window?.let { resizeWhenSoftInputShown(it) }
return dialog
}

private fun setUpToolbar() {
val toolbar = contentView.findViewById<Toolbar>(R.id.add_edit_reminder_toolbar)
toolbar.title =
binding.addEditReminderToolbar.title =
getString(
when (dialogMode) {
is DialogMode.Add -> R.string.add_review_reminder
Expand All @@ -176,25 +174,23 @@ class AddEditReminderDialog : DialogFragment() {
}

private fun setUpTimeButton() {
val timeButton = contentView.findViewById<MaterialButton>(R.id.add_edit_reminder_time_button)
timeButton.setOnClickListener {
binding.addEditReminderTimeButton.setOnClickListener {
Timber.i("Time button clicked")
val time = viewModel.time.value ?: ReviewReminderTime.getCurrentTime()
showTimePickerDialog(time.hour, time.minute)
}
viewModel.time.observe(this) { time ->
timeButton.text = time.toFormattedString(requireContext())
binding.addEditReminderTimeButton.text = time.toFormattedString(requireContext())
}
}

private fun setInitialDeckSelection() {
val deckName = contentView.findViewById<TextView>(R.id.add_edit_reminder_deck_name)
deckName.setOnClickListener { startDeckSelection(all = true, filtered = true) }
binding.addEditReminderDeckName.setOnClickListener { startDeckSelection(all = true, filtered = true) }
launchCatchingTask {
Timber.d("Setting up deck name view")
val (selectedDeckId, selectedDeckName) = getValidDeckSelection()
Timber.d("Initial selection of deck %s(id=%d)", selectedDeckName, selectedDeckId)
deckName.text = selectedDeckName
binding.addEditReminderDeckName.text = selectedDeckName
viewModel.setDeckSelected(selectedDeckId)
}
}
Expand Down Expand Up @@ -231,34 +227,28 @@ class AddEditReminderDialog : DialogFragment() {
}

private fun setUpAdvancedDropdown() {
val advancedDropdown = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_advanced_dropdown)
val advancedDropdownIcon = contentView.findViewById<ImageView>(R.id.add_edit_reminder_advanced_dropdown_icon)
val advancedContent = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_advanced_content)

advancedDropdown.setOnClickListener {
binding.addEditReminderAdvancedDropdown.setOnClickListener {
viewModel.toggleAdvancedSettingsOpen()
}
viewModel.advancedSettingsOpen.observe(this) { advancedSettingsOpen ->
when (advancedSettingsOpen) {
true -> {
advancedContent.isVisible = true
advancedDropdownIcon.setBackgroundResource(DROPDOWN_EXPANDED_CHEVRON)
binding.addEditReminderAdvancedContent.isVisible = true
binding.addEditReminderAdvancedDropdownIcon.setBackgroundResource(DROPDOWN_EXPANDED_CHEVRON)
}
false -> {
advancedContent.isVisible = false
advancedDropdownIcon.setBackgroundResource(DROPDOWN_COLLAPSED_CHEVRON)
binding.addEditReminderAdvancedContent.isVisible = false
binding.addEditReminderAdvancedDropdownIcon.setBackgroundResource(DROPDOWN_COLLAPSED_CHEVRON)
}
}
}
}

private fun setUpCardThresholdInput() {
val cardThresholdInputWrapper = contentView.findViewById<TextInputLayout>(R.id.add_edit_reminder_card_threshold_input_wrapper)
val cardThresholdInput = contentView.findViewById<EditText>(R.id.add_edit_reminder_card_threshold_input)
cardThresholdInput.setText(viewModel.cardTriggerThreshold.value.toString())
cardThresholdInput.doOnTextChanged { text, _, _, _ ->
binding.addEditReminderCardThresholdInput.setText(viewModel.cardTriggerThreshold.value.toString())
binding.addEditReminderCardThresholdInput.doOnTextChanged { text, _, _, _ ->
val value: Int? = text.toString().toIntOrNull()
cardThresholdInputWrapper.error =
binding.addEditReminderCardThresholdInputWrapper.error =
when {
(value == null) -> "Please enter a whole number of cards"
(value < 0) -> "The threshold must be at least 0"
Expand All @@ -269,16 +259,79 @@ class AddEditReminderDialog : DialogFragment() {
}

private fun setUpOnlyNotifyIfNoReviewsCheckbox() {
val contentSection = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_only_notify_if_no_reviews_section)
val checkbox = contentView.findViewById<MaterialCheckBox>(R.id.add_edit_reminder_only_notify_if_no_reviews_checkbox)
contentSection.setOnClickListener {
binding.addEditReminderOnlyNotifyIfNoReviewsSection.setOnClickListener {
viewModel.toggleOnlyNotifyIfNoReviews()
}
checkbox.setOnClickListener {
binding.addEditReminderOnlyNotifyIfNoReviewsCheckbox.setOnClickListener {
viewModel.toggleOnlyNotifyIfNoReviews()
}
viewModel.onlyNotifyIfNoReviews.observe(this) { onlyNotifyIfNoReviews ->
checkbox.isChecked = onlyNotifyIfNoReviews
binding.addEditReminderOnlyNotifyIfNoReviewsCheckbox.isChecked = onlyNotifyIfNoReviews
}
}

/**
* Convenience data class for setting up the checkboxes for whether to count new, learning, and review cards
* when considering the card trigger threshold.
* @see setUpCountCheckboxes
*/
private data class CountViewsAndActions(
val section: LinearLayout,
val textView: FixedTextView,
val checkbox: MaterialCheckBox,
val actionOnClick: () -> Unit,
val state: LiveData<Boolean>,
)

/**
* Sets up the checkboxes for whether to count new, learning, and review cards when considering the card trigger threshold.
* @see CountViewsAndActions
*/
private fun setUpCountCheckboxes() {
val countViewsAndActionsItems =
listOf(
CountViewsAndActions(
section = binding.addEditReminderCountNewSection,
textView = binding.addEditReminderCountNewLabel,
checkbox = binding.addEditReminderCountNewCheckbox,
actionOnClick = viewModel::toggleCountNew,
state = viewModel.countNew,
),
CountViewsAndActions(
section = binding.addEditReminderCountLrnSection,
textView = binding.addEditReminderCountLrnLabel,
checkbox = binding.addEditReminderCountLrnCheckbox,
actionOnClick = viewModel::toggleCountLrn,
state = viewModel.countLrn,
),
CountViewsAndActions(
section = binding.addEditReminderCountRevSection,
textView = binding.addEditReminderCountRevLabel,
checkbox = binding.addEditReminderCountRevCheckbox,
actionOnClick = viewModel::toggleCountRev,
state = viewModel.countRev,
),
)

countViewsAndActionsItems.forEachIndexed { i, item ->
item.section.setOnClickListener { item.actionOnClick() }

// Manually split the string resource so that we can color just the review state part
val (reviewState, colorAttr) = REVIEW_STATE_STRINGS_AND_COLORS.entries.elementAt(i)
val splitString = getString(R.string.review_reminders_include_review_state_for_threshold_do_not_translate).split("%s")
Copy link
Member

@david-allison david-allison Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome.

I'd consider defining partition (pseudocode below, needs docs, maybe tests).

  • Minor bug that if %s is missing, things blow up - probably fine to ignore
  • Minor bug that the string is truncated if there are too many %s - fixed below
  • Ensure that a change: %s to %1$s would fail a unit test
    fun String.partition(delimiter: String): Pair<String, String> = this.split(delimiter, limit = 2)
    val (before, after) = "a %s b".partition("%s")

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, elegant! Implemented, I've added unit tests too.

item.textView.text =
buildSpannedString {
append(splitString[0])
color(MaterialColors.getColor(requireContext(), colorAttr, 0)) {
append(getString(reviewState))
}
append(splitString[1])
}

item.checkbox.setOnClickListener { item.actionOnClick() }
item.state.observe(this) { value ->
item.checkbox.isChecked = value
}
}
}

Expand Down Expand Up @@ -321,9 +374,8 @@ class AddEditReminderDialog : DialogFragment() {
private fun onSubmit() {
Timber.i("Submitted dialog")
// Do nothing if numerical fields are invalid
val cardThresholdInputWrapper = contentView.findViewById<TextInputLayout>(R.id.add_edit_reminder_card_threshold_input_wrapper)
cardThresholdInputWrapper.error?.let {
contentView.showSnackbar(R.string.something_wrong)
binding.addEditReminderCardThresholdInputWrapper.error?.let {
binding.root.showSnackbar(R.string.something_wrong)
return
}

Expand Down Expand Up @@ -391,6 +443,17 @@ class AddEditReminderDialog : DialogFragment() {
*/
private const val TIME_PICKER_TAG = "REMINDER_TIME_PICKER_DIALOG"

/**
* String resources and colors to display them in for the different review states (new, learning, review).
* Used for styling the advanced options for which card types to count towards the card trigger threshold.
*/
private val REVIEW_STATE_STRINGS_AND_COLORS =
mapOf(
R.string.new_review_state_do_not_translate to R.attr.newCountColor,
R.string.learning_review_state_do_not_translate to R.attr.learnCountColor,
R.string.reviewing_review_state_do_not_translate to R.attr.reviewCountColor,
)

/**
* Creates a new instance of this dialog with the given dialog mode.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,33 @@ class AddEditReminderDialogViewModel(
)
val onlyNotifyIfNoReviews: LiveData<Boolean> = _onlyNotifyIfNoReviews

private val _countNew =
MutableLiveData(
when (dialogMode) {
is AddEditReminderDialog.DialogMode.Add -> INITIAL_COUNT_NEW
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.thresholdFilter.countNew
},
)
val countNew: LiveData<Boolean> = _countNew

private val _countLrn =
MutableLiveData(
when (dialogMode) {
is AddEditReminderDialog.DialogMode.Add -> INITIAL_COUNT_LRN
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.thresholdFilter.countLrn
},
)
val countLrn: LiveData<Boolean> = _countLrn

private val _countRev =
MutableLiveData(
when (dialogMode) {
is AddEditReminderDialog.DialogMode.Add -> INITIAL_COUNT_REV
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.thresholdFilter.countRev
},
)
val countRev: LiveData<Boolean> = _countRev

private val _advancedSettingsOpen = MutableLiveData(INITIAL_ADVANCED_SETTINGS_OPEN)
val advancedSettingsOpen: LiveData<Boolean> = _advancedSettingsOpen

Expand All @@ -121,6 +148,21 @@ class AddEditReminderDialogViewModel(
_onlyNotifyIfNoReviews.value = !(_onlyNotifyIfNoReviews.value ?: false)
}

fun toggleCountNew() {
Timber.i("Toggled count new from %s", _countNew.value)
_countNew.value = !(_countNew.value ?: false)
}

fun toggleCountLrn() {
Timber.i("Toggled count lrn from %s", _countLrn.value)
_countLrn.value = !(_countLrn.value ?: false)
}

fun toggleCountRev() {
Timber.i("Toggled count rev from %s", _countRev.value)
_countRev.value = !(_countRev.value ?: false)
}

fun toggleAdvancedSettingsOpen() {
Timber.i("Toggled advanced settings open from %s", _advancedSettingsOpen.value)
_advancedSettingsOpen.value = !(_advancedSettingsOpen.value ?: false)
Expand Down Expand Up @@ -151,6 +193,12 @@ class AddEditReminderDialogViewModel(
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.enabled
},
onlyNotifyIfNoReviews = onlyNotifyIfNoReviews.value ?: INITIAL_ONLY_NOTIFY_IF_NO_REVIEWS,
thresholdFilter =
ReviewReminderThresholdFilter(
countNew = countNew.value ?: INITIAL_COUNT_NEW,
countLrn = countLrn.value ?: INITIAL_COUNT_LRN,
countRev = countRev.value ?: INITIAL_COUNT_REV,
),
)

companion object {
Expand All @@ -175,5 +223,25 @@ class AddEditReminderDialogViewModel(
* We start with it closed to avoid overwhelming the user.
*/
private const val INITIAL_ADVANCED_SETTINGS_OPEN = false

/**
* The default setting for whether new cards are counted when checking the card trigger threshold.
* This value, and the other default settings for whether certain kinds of cards are counted
* when checking the card trigger threshold, are all set to true, as removing some card types
* from card trigger threshold consideration is a form of advanced review reminder customization.
*/
private const val INITIAL_COUNT_NEW = true

/**
* The default setting for whether cards in learning are counted when checking the card trigger threshold.
* @see INITIAL_COUNT_NEW
*/
private const val INITIAL_COUNT_LRN = true

/**
* The default setting for whether cards in review are counted when checking the card trigger threshold.
* @see INITIAL_COUNT_NEW
*/
private const val INITIAL_COUNT_REV = true
}
}
Loading