Skip to content

Commit 8866c30

Browse files
committed
feat(reminders): threshold filters
GSoC 2025: Review Reminders Add a group of new advanced review reminder options: count new cards, count cards in learning, and count cards in review. When the review reminder is about to send a notification and checks to see if the amount of cards in the deck is greater than the card trigger threshold, it examines these options to check if it should count and consider new cards, cards in learning, and cards in review. Adds three new checkboxes to the AddEditReminderDialog to toggle these booleans on or off, with colored text for the corresponding review state boolean. Edits some logic in NotificationService to add up cards only from selected card type when determining whether the card trigger threshold is met. Adds three new boolean fields to store the states of these settings to ReviewReminder. Adds unit tests. Modifies some unit test utilities for convenience.
1 parent 9913bb8 commit 8866c30

8 files changed

Lines changed: 528 additions & 254 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt

Lines changed: 105 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,26 @@ import android.content.res.Configuration
2121
import android.os.Bundle
2222
import android.os.Parcelable
2323
import android.text.format.DateFormat
24-
import android.view.View
25-
import android.widget.EditText
26-
import android.widget.ImageView
2724
import android.widget.LinearLayout
28-
import android.widget.TextView
2925
import androidx.appcompat.app.AlertDialog
30-
import androidx.appcompat.widget.Toolbar
3126
import androidx.core.os.BundleCompat
27+
import androidx.core.text.buildSpannedString
28+
import androidx.core.text.color
3229
import androidx.core.view.isVisible
3330
import androidx.core.widget.doOnTextChanged
3431
import androidx.fragment.app.DialogFragment
3532
import androidx.fragment.app.setFragmentResult
3633
import androidx.fragment.app.setFragmentResultListener
3734
import androidx.fragment.app.viewModels
38-
import com.google.android.material.button.MaterialButton
35+
import androidx.lifecycle.LiveData
3936
import com.google.android.material.checkbox.MaterialCheckBox
40-
import com.google.android.material.textfield.TextInputLayout
37+
import com.google.android.material.color.MaterialColors
4138
import com.google.android.material.timepicker.MaterialTimePicker
4239
import com.google.android.material.timepicker.TimeFormat
4340
import com.ichi2.anki.ALL_DECKS_ID
4441
import com.ichi2.anki.CollectionManager.withCol
4542
import com.ichi2.anki.R
43+
import com.ichi2.anki.databinding.DialogAddEditReminderBinding
4644
import com.ichi2.anki.dialogs.ConfirmationDialog
4745
import com.ichi2.anki.isDefaultDeckEmpty
4846
import com.ichi2.anki.launchCatchingTask
@@ -53,6 +51,7 @@ import com.ichi2.anki.settings.Prefs
5351
import com.ichi2.anki.snackbar.showSnackbar
5452
import com.ichi2.anki.startDeckSelection
5553
import com.ichi2.anki.utils.ext.showDialogFragment
54+
import com.ichi2.ui.FixedTextView
5655
import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown
5756
import com.ichi2.utils.Permissions
5857
import com.ichi2.utils.customView
@@ -89,7 +88,7 @@ class AddEditReminderDialog : DialogFragment() {
8988

9089
private val viewModel: AddEditReminderDialogViewModel by viewModels()
9190

92-
private lateinit var contentView: View
91+
private lateinit var binding: DialogAddEditReminderBinding
9392

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

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

111110
val dialogBuilder =
112111
AlertDialog.Builder(requireActivity()).apply {
113-
customView(contentView)
112+
customView(binding.root)
114113
positiveButton(R.string.dialog_ok)
115114
neutralButton(R.string.dialog_cancel)
116115

@@ -138,6 +137,7 @@ class AddEditReminderDialog : DialogFragment() {
138137
setUpAdvancedDropdown()
139138
setUpCardThresholdInput()
140139
setUpOnlyNotifyIfNoReviewsCheckbox()
140+
setUpCountCheckboxes()
141141

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

163162
dialog.window?.let { resizeWhenSoftInputShown(it) }
164163
return dialog
165164
}
166165

167166
private fun setUpToolbar() {
168-
val toolbar = contentView.findViewById<Toolbar>(R.id.add_edit_reminder_toolbar)
169-
toolbar.title =
167+
binding.addEditReminderToolbar.title =
170168
getString(
171169
when (dialogMode) {
172170
is DialogMode.Add -> R.string.add_review_reminder
@@ -176,25 +174,23 @@ class AddEditReminderDialog : DialogFragment() {
176174
}
177175

178176
private fun setUpTimeButton() {
179-
val timeButton = contentView.findViewById<MaterialButton>(R.id.add_edit_reminder_time_button)
180-
timeButton.setOnClickListener {
177+
binding.addEditReminderTimeButton.setOnClickListener {
181178
Timber.i("Time button clicked")
182179
val time = viewModel.time.value ?: ReviewReminderTime.getCurrentTime()
183180
showTimePickerDialog(time.hour, time.minute)
184181
}
185182
viewModel.time.observe(this) { time ->
186-
timeButton.text = time.toFormattedString(requireContext())
183+
binding.addEditReminderTimeButton.text = time.toFormattedString(requireContext())
187184
}
188185
}
189186

190187
private fun setInitialDeckSelection() {
191-
val deckName = contentView.findViewById<TextView>(R.id.add_edit_reminder_deck_name)
192-
deckName.setOnClickListener { startDeckSelection(all = true, filtered = true) }
188+
binding.addEditReminderDeckName.setOnClickListener { startDeckSelection(all = true, filtered = true) }
193189
launchCatchingTask {
194190
Timber.d("Setting up deck name view")
195191
val (selectedDeckId, selectedDeckName) = getValidDeckSelection()
196192
Timber.d("Initial selection of deck %s(id=%d)", selectedDeckName, selectedDeckId)
197-
deckName.text = selectedDeckName
193+
binding.addEditReminderDeckName.text = selectedDeckName
198194
viewModel.setDeckSelected(selectedDeckId)
199195
}
200196
}
@@ -231,34 +227,28 @@ class AddEditReminderDialog : DialogFragment() {
231227
}
232228

233229
private fun setUpAdvancedDropdown() {
234-
val advancedDropdown = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_advanced_dropdown)
235-
val advancedDropdownIcon = contentView.findViewById<ImageView>(R.id.add_edit_reminder_advanced_dropdown_icon)
236-
val advancedContent = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_advanced_content)
237-
238-
advancedDropdown.setOnClickListener {
230+
binding.addEditReminderAdvancedDropdown.setOnClickListener {
239231
viewModel.toggleAdvancedSettingsOpen()
240232
}
241233
viewModel.advancedSettingsOpen.observe(this) { advancedSettingsOpen ->
242234
when (advancedSettingsOpen) {
243235
true -> {
244-
advancedContent.isVisible = true
245-
advancedDropdownIcon.setBackgroundResource(DROPDOWN_EXPANDED_CHEVRON)
236+
binding.addEditReminderAdvancedContent.isVisible = true
237+
binding.addEditReminderAdvancedDropdownIcon.setBackgroundResource(DROPDOWN_EXPANDED_CHEVRON)
246238
}
247239
false -> {
248-
advancedContent.isVisible = false
249-
advancedDropdownIcon.setBackgroundResource(DROPDOWN_COLLAPSED_CHEVRON)
240+
binding.addEditReminderAdvancedContent.isVisible = false
241+
binding.addEditReminderAdvancedDropdownIcon.setBackgroundResource(DROPDOWN_COLLAPSED_CHEVRON)
250242
}
251243
}
252244
}
253245
}
254246

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

271261
private fun setUpOnlyNotifyIfNoReviewsCheckbox() {
272-
val contentSection = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_only_notify_if_no_reviews_section)
273-
val checkbox = contentView.findViewById<MaterialCheckBox>(R.id.add_edit_reminder_only_notify_if_no_reviews_checkbox)
274-
contentSection.setOnClickListener {
262+
binding.addEditReminderOnlyNotifyIfNoReviewsSection.setOnClickListener {
275263
viewModel.toggleOnlyNotifyIfNoReviews()
276264
}
277-
checkbox.setOnClickListener {
265+
binding.addEditReminderOnlyNotifyIfNoReviewsCheckbox.setOnClickListener {
278266
viewModel.toggleOnlyNotifyIfNoReviews()
279267
}
280268
viewModel.onlyNotifyIfNoReviews.observe(this) { onlyNotifyIfNoReviews ->
281-
checkbox.isChecked = onlyNotifyIfNoReviews
269+
binding.addEditReminderOnlyNotifyIfNoReviewsCheckbox.isChecked = onlyNotifyIfNoReviews
270+
}
271+
}
272+
273+
/**
274+
* Convenience data class for setting up the checkboxes for whether to count new, learning, and review cards
275+
* when considering the card trigger threshold.
276+
* @see setUpCountCheckboxes
277+
*/
278+
private data class CountViewsAndActions(
279+
val section: LinearLayout,
280+
val textView: FixedTextView,
281+
val checkbox: MaterialCheckBox,
282+
val actionOnClick: () -> Unit,
283+
val state: LiveData<Boolean>,
284+
)
285+
286+
/**
287+
* Sets up the checkboxes for whether to count new, learning, and review cards when considering the card trigger threshold.
288+
* @see CountViewsAndActions
289+
*/
290+
private fun setUpCountCheckboxes() {
291+
val countViewsAndActionsItems =
292+
listOf(
293+
CountViewsAndActions(
294+
section = binding.addEditReminderCountNewSection,
295+
textView = binding.addEditReminderCountNewLabel,
296+
checkbox = binding.addEditReminderCountNewCheckbox,
297+
actionOnClick = viewModel::toggleCountNew,
298+
state = viewModel.countNew,
299+
),
300+
CountViewsAndActions(
301+
section = binding.addEditReminderCountLrnSection,
302+
textView = binding.addEditReminderCountLrnLabel,
303+
checkbox = binding.addEditReminderCountLrnCheckbox,
304+
actionOnClick = viewModel::toggleCountLrn,
305+
state = viewModel.countLrn,
306+
),
307+
CountViewsAndActions(
308+
section = binding.addEditReminderCountRevSection,
309+
textView = binding.addEditReminderCountRevLabel,
310+
checkbox = binding.addEditReminderCountRevCheckbox,
311+
actionOnClick = viewModel::toggleCountRev,
312+
state = viewModel.countRev,
313+
),
314+
)
315+
316+
countViewsAndActionsItems.forEachIndexed { i, item ->
317+
item.section.setOnClickListener { item.actionOnClick() }
318+
319+
// Manually split the string resource so that we can color just the review state part
320+
val (reviewState, colorAttr) = REVIEW_STATE_STRINGS_AND_COLORS.entries.elementAt(i)
321+
val splitString = getString(R.string.review_reminders_include_review_state_for_threshold_do_not_translate).split("%s")
322+
item.textView.text =
323+
buildSpannedString {
324+
append(splitString[0])
325+
color(MaterialColors.getColor(requireContext(), colorAttr, 0)) {
326+
append(getString(reviewState))
327+
}
328+
append(splitString[1])
329+
}
330+
331+
item.checkbox.setOnClickListener { item.actionOnClick() }
332+
item.state.observe(this) { value ->
333+
item.checkbox.isChecked = value
334+
}
282335
}
283336
}
284337

@@ -321,9 +374,8 @@ class AddEditReminderDialog : DialogFragment() {
321374
private fun onSubmit() {
322375
Timber.i("Submitted dialog")
323376
// Do nothing if numerical fields are invalid
324-
val cardThresholdInputWrapper = contentView.findViewById<TextInputLayout>(R.id.add_edit_reminder_card_threshold_input_wrapper)
325-
cardThresholdInputWrapper.error?.let {
326-
contentView.showSnackbar(R.string.something_wrong)
377+
binding.addEditReminderCardThresholdInputWrapper.error?.let {
378+
binding.root.showSnackbar(R.string.something_wrong)
327379
return
328380
}
329381

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

446+
/**
447+
* String resources and colors to display them in for the different review states (new, learning, review).
448+
* Used for styling the advanced options for which card types to count towards the card trigger threshold.
449+
*/
450+
private val REVIEW_STATE_STRINGS_AND_COLORS =
451+
mapOf(
452+
R.string.new_review_state_do_not_translate to R.attr.newCountColor,
453+
R.string.learning_review_state_do_not_translate to R.attr.learnCountColor,
454+
R.string.reviewing_review_state_do_not_translate to R.attr.reviewCountColor,
455+
)
456+
394457
/**
395458
* Creates a new instance of this dialog with the given dialog mode.
396459
*/

AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,33 @@ class AddEditReminderDialogViewModel(
9898
)
9999
val onlyNotifyIfNoReviews: LiveData<Boolean> = _onlyNotifyIfNoReviews
100100

101+
private val _countNew =
102+
MutableLiveData(
103+
when (dialogMode) {
104+
is AddEditReminderDialog.DialogMode.Add -> INITIAL_COUNT_NEW
105+
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.thresholdFilter.countNew
106+
},
107+
)
108+
val countNew: LiveData<Boolean> = _countNew
109+
110+
private val _countLrn =
111+
MutableLiveData(
112+
when (dialogMode) {
113+
is AddEditReminderDialog.DialogMode.Add -> INITIAL_COUNT_LRN
114+
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.thresholdFilter.countLrn
115+
},
116+
)
117+
val countLrn: LiveData<Boolean> = _countLrn
118+
119+
private val _countRev =
120+
MutableLiveData(
121+
when (dialogMode) {
122+
is AddEditReminderDialog.DialogMode.Add -> INITIAL_COUNT_REV
123+
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.thresholdFilter.countRev
124+
},
125+
)
126+
val countRev: LiveData<Boolean> = _countRev
127+
101128
private val _advancedSettingsOpen = MutableLiveData(INITIAL_ADVANCED_SETTINGS_OPEN)
102129
val advancedSettingsOpen: LiveData<Boolean> = _advancedSettingsOpen
103130

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

151+
fun toggleCountNew() {
152+
Timber.i("Toggled count new from %s", _countNew.value)
153+
_countNew.value = !(_countNew.value ?: false)
154+
}
155+
156+
fun toggleCountLrn() {
157+
Timber.i("Toggled count lrn from %s", _countLrn.value)
158+
_countLrn.value = !(_countLrn.value ?: false)
159+
}
160+
161+
fun toggleCountRev() {
162+
Timber.i("Toggled count rev from %s", _countRev.value)
163+
_countRev.value = !(_countRev.value ?: false)
164+
}
165+
124166
fun toggleAdvancedSettingsOpen() {
125167
Timber.i("Toggled advanced settings open from %s", _advancedSettingsOpen.value)
126168
_advancedSettingsOpen.value = !(_advancedSettingsOpen.value ?: false)
@@ -151,6 +193,12 @@ class AddEditReminderDialogViewModel(
151193
is AddEditReminderDialog.DialogMode.Edit -> dialogMode.reminderToBeEdited.enabled
152194
},
153195
onlyNotifyIfNoReviews = onlyNotifyIfNoReviews.value ?: INITIAL_ONLY_NOTIFY_IF_NO_REVIEWS,
196+
thresholdFilter =
197+
ReviewReminderThresholdFilter(
198+
countNew = countNew.value ?: INITIAL_COUNT_NEW,
199+
countLrn = countLrn.value ?: INITIAL_COUNT_LRN,
200+
countRev = countRev.value ?: INITIAL_COUNT_REV,
201+
),
154202
)
155203

156204
companion object {
@@ -175,5 +223,25 @@ class AddEditReminderDialogViewModel(
175223
* We start with it closed to avoid overwhelming the user.
176224
*/
177225
private const val INITIAL_ADVANCED_SETTINGS_OPEN = false
226+
227+
/**
228+
* The default setting for whether new cards are counted when checking the card trigger threshold.
229+
* This value, and the other default settings for whether certain kinds of cards are counted
230+
* when checking the card trigger threshold, are all set to true, as removing some card types
231+
* from card trigger threshold consideration is a form of advanced review reminder customization.
232+
*/
233+
private const val INITIAL_COUNT_NEW = true
234+
235+
/**
236+
* The default setting for whether cards in learning are counted when checking the card trigger threshold.
237+
* @see INITIAL_COUNT_NEW
238+
*/
239+
private const val INITIAL_COUNT_LRN = true
240+
241+
/**
242+
* The default setting for whether cards in review are counted when checking the card trigger threshold.
243+
* @see INITIAL_COUNT_NEW
244+
*/
245+
private const val INITIAL_COUNT_REV = true
178246
}
179247
}

0 commit comments

Comments
 (0)