Skip to content

Commit 0793e7a

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. 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.
1 parent 2ddcb22 commit 0793e7a

File tree

6 files changed

+362
-6
lines changed

6 files changed

+362
-6
lines changed

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ import androidx.fragment.app.DialogFragment
3636
import androidx.fragment.app.setFragmentResult
3737
import androidx.fragment.app.setFragmentResultListener
3838
import androidx.fragment.app.viewModels
39+
import androidx.lifecycle.LiveData
3940
import androidx.lifecycle.viewmodel.initializer
4041
import androidx.lifecycle.viewmodel.viewModelFactory
4142
import com.google.android.material.button.MaterialButton
43+
import com.google.android.material.checkbox.MaterialCheckBox
4244
import com.google.android.material.timepicker.MaterialTimePicker
4345
import com.google.android.material.timepicker.TimeFormat
4446
import com.ichi2.anki.DeckSpinnerSelection
@@ -98,6 +100,9 @@ class AddEditReminderDialog : DialogFragment() {
98100
is ReviewReminderScope.DeckSpecific -> mode.schedulerScope.did
99101
},
100102
initialCardTriggerThreshold = INITIAL_CARD_THRESHOLD,
103+
initialCountNew = INITIAL_COUNT_NEW,
104+
initialCountLrn = INITIAL_COUNT_LRN,
105+
initialCountRev = INITIAL_COUNT_REV,
101106
initialAdvancedSettingsOpen = INITIAL_ADVANCED_SETTINGS_OPEN,
102107
)
103108
is DialogMode.Edit ->
@@ -109,6 +114,9 @@ class AddEditReminderDialog : DialogFragment() {
109114
is ReviewReminderScope.DeckSpecific -> mode.reminderToBeEdited.scope.did
110115
},
111116
initialCardTriggerThreshold = mode.reminderToBeEdited.cardTriggerThreshold.threshold,
117+
initialCountNew = mode.reminderToBeEdited.countNew,
118+
initialCountLrn = mode.reminderToBeEdited.countLrn,
119+
initialCountRev = mode.reminderToBeEdited.countRev,
112120
initialAdvancedSettingsOpen = INITIAL_ADVANCED_SETTINGS_OPEN,
113121
)
114122
}
@@ -164,6 +172,7 @@ class AddEditReminderDialog : DialogFragment() {
164172
setUpDeckSpinner()
165173
setUpAdvancedDropdown()
166174
setUpCardThresholdInput()
175+
setUpCountCheckboxes()
167176

168177
// For getting the result of the deck selection sub-dialog from ScheduleReminders
169178
// See ScheduleReminders.onDeckSelected for more information
@@ -250,6 +259,54 @@ class AddEditReminderDialog : DialogFragment() {
250259
}
251260
}
252261

262+
/**
263+
* Convenience data class for setting up the checkboxes for whether to count new, learning, and review cards
264+
* when considering the card trigger threshold.
265+
* @see setUpCountCheckboxes
266+
*/
267+
private data class CountViewsAndActions(
268+
val section: LinearLayout,
269+
val checkbox: MaterialCheckBox,
270+
val actionOnClick: () -> Unit,
271+
val state: LiveData<Boolean>,
272+
)
273+
274+
/**
275+
* Sets up the checkboxes for whether to count new, learning, and review cards when considering the card trigger threshold.
276+
* @see CountViewsAndActions
277+
*/
278+
private fun setUpCountCheckboxes() {
279+
val countViewsAndActionsItems =
280+
listOf(
281+
CountViewsAndActions(
282+
section = contentView.findViewById(R.id.add_edit_reminder_count_new_section),
283+
checkbox = contentView.findViewById(R.id.add_edit_reminder_count_new_checkbox),
284+
actionOnClick = viewModel::toggleCountNew,
285+
state = viewModel.countNew,
286+
),
287+
CountViewsAndActions(
288+
section = contentView.findViewById(R.id.add_edit_reminder_count_lrn_section),
289+
checkbox = contentView.findViewById(R.id.add_edit_reminder_count_lrn_checkbox),
290+
actionOnClick = viewModel::toggleCountLrn,
291+
state = viewModel.countLrn,
292+
),
293+
CountViewsAndActions(
294+
section = contentView.findViewById(R.id.add_edit_reminder_count_rev_section),
295+
checkbox = contentView.findViewById(R.id.add_edit_reminder_count_rev_checkbox),
296+
actionOnClick = viewModel::toggleCountRev,
297+
state = viewModel.countRev,
298+
),
299+
)
300+
301+
countViewsAndActionsItems.forEach { item ->
302+
item.section.setOnClickListener { item.actionOnClick() }
303+
item.checkbox.setOnClickListener { item.actionOnClick() }
304+
item.state.observe(this) { value ->
305+
item.checkbox.isChecked = value
306+
}
307+
}
308+
}
309+
253310
/**
254311
* Show the time picker dialog for selecting a time with a given hour and minute.
255312
* Does not automatically dismiss the old dialog.
@@ -327,6 +384,9 @@ class AddEditReminderDialog : DialogFragment() {
327384
is DialogMode.Add -> true
328385
is DialogMode.Edit -> mode.reminderToBeEdited.enabled
329386
},
387+
countNew = viewModel.countNew.value ?: INITIAL_COUNT_NEW,
388+
countLrn = viewModel.countLrn.value ?: INITIAL_COUNT_LRN,
389+
countRev = viewModel.countRev.value ?: INITIAL_COUNT_REV,
330390
)
331391

332392
Timber.d("Reminder to be returned: %s", reminderToBeReturned)
@@ -391,6 +451,27 @@ class AddEditReminderDialog : DialogFragment() {
391451
*/
392452
private const val INITIAL_CARD_THRESHOLD: Int = 1
393453

454+
/**
455+
* The default setting for whether new cards are counted when checking the card trigger threshold.
456+
* Defaults to true, as removing some card types from card trigger threshold consideration is a form
457+
* of advanced review reminder customization.
458+
*/
459+
private const val INITIAL_COUNT_NEW = true
460+
461+
/**
462+
* The default setting for whether cards in learning are counted when checking the card trigger threshold.
463+
* Defaults to true, as removing some card types from card trigger threshold consideration is a form
464+
* of advanced review reminder customization.
465+
*/
466+
private const val INITIAL_COUNT_LRN = true
467+
468+
/**
469+
* The default setting for whether cards in review are counted when checking the card trigger threshold.
470+
* Defaults to true, as removing some card types from card trigger threshold consideration is a form
471+
* of advanced review reminder customization.
472+
*/
473+
private const val INITIAL_COUNT_REV = true
474+
394475
/**
395476
* Whether the advanced settings dropdown is initially open.
396477
* We start with it closed to avoid overwhelming the user.

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ class AddEditReminderDialogViewModel(
3333
initialDeckSelected: DeckId,
3434
initialCardTriggerThreshold: Int,
3535
initialAdvancedSettingsOpen: Boolean,
36+
initialCountNew: Boolean,
37+
initialCountLrn: Boolean,
38+
initialCountRev: Boolean,
3639
) : ViewModel() {
3740
private val _time = MutableLiveData(initialTime)
3841
val time: LiveData<ReviewReminderTime> = _time
@@ -48,6 +51,15 @@ class AddEditReminderDialogViewModel(
4851
private val _cardTriggerThreshold = MutableLiveData(initialCardTriggerThreshold)
4952
val cardTriggerThreshold: LiveData<Int> = _cardTriggerThreshold
5053

54+
private val _countNew = MutableLiveData(initialCountNew)
55+
val countNew: LiveData<Boolean> = _countNew
56+
57+
private val _countLrn = MutableLiveData(initialCountLrn)
58+
val countLrn: LiveData<Boolean> = _countLrn
59+
60+
private val _countRev = MutableLiveData(initialCountRev)
61+
val countRev: LiveData<Boolean> = _countRev
62+
5163
private val _advancedSettingsOpen = MutableLiveData(initialAdvancedSettingsOpen)
5264
val advancedSettingsOpen: LiveData<Boolean> = _advancedSettingsOpen
5365

@@ -66,6 +78,21 @@ class AddEditReminderDialogViewModel(
6678
_cardTriggerThreshold.value = threshold
6779
}
6880

81+
fun toggleCountNew() {
82+
Timber.d("Toggled count new from %s", _countNew.value)
83+
_countNew.value = !(_countNew.value ?: false)
84+
}
85+
86+
fun toggleCountLrn() {
87+
Timber.d("Toggled count lrn from %s", _countLrn.value)
88+
_countLrn.value = !(_countLrn.value ?: false)
89+
}
90+
91+
fun toggleCountRev() {
92+
Timber.d("Toggled count rev from %s", _countRev.value)
93+
_countRev.value = !(_countRev.value ?: false)
94+
}
95+
6996
fun toggleAdvancedSettingsOpen() {
7097
Timber.d("Toggled advanced settings open from %s", _advancedSettingsOpen.value)
7198
_advancedSettingsOpen.value = !(_advancedSettingsOpen.value ?: false)

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,14 @@ sealed class ReviewReminderScope : Parcelable {
162162
* Preferably, also add some unit tests to ensure your migration works properly on all user devices once your update is rolled out.
163163
* See ReviewRemindersDatabaseTest for examples on how to do this.
164164
*
165-
* TODO: add remaining fields planned for GSoC 2025.
166-
*
167165
* @param id Unique, auto-incremented ID of the review reminder.
168166
* @param time See [ReviewReminderTime].
169167
* @param cardTriggerThreshold See [ReviewReminderCardTriggerThreshold].
170168
* @param scope See [ReviewReminderScope].
171169
* @param enabled Whether the review reminder's notifications are active or disabled.
170+
* @param countNew Whether new cards are counted when checking the [cardTriggerThreshold].
171+
* @param countLrn Whether learning cards are counted when checking the [cardTriggerThreshold].
172+
* @param countRev Whether review cards are counted when checking the [cardTriggerThreshold].
172173
*/
173174
@Serializable
174175
@Parcelize
@@ -179,6 +180,9 @@ data class ReviewReminder private constructor(
179180
val cardTriggerThreshold: ReviewReminderCardTriggerThreshold,
180181
val scope: ReviewReminderScope,
181182
var enabled: Boolean,
183+
val countNew: Boolean,
184+
val countLrn: Boolean,
185+
val countRev: Boolean,
182186
) : Parcelable,
183187
ReviewReminderSchema {
184188
companion object {
@@ -192,12 +196,18 @@ data class ReviewReminder private constructor(
192196
cardTriggerThreshold: ReviewReminderCardTriggerThreshold,
193197
scope: ReviewReminderScope = ReviewReminderScope.Global,
194198
enabled: Boolean = true,
199+
countNew: Boolean = true,
200+
countLrn: Boolean = true,
201+
countRev: Boolean = true,
195202
) = ReviewReminder(
196203
id = ReviewReminderId.getAndIncrementNextFreeReminderId(),
197204
time,
198205
cardTriggerThreshold,
199206
scope,
200207
enabled,
208+
countNew,
209+
countLrn,
210+
countRev,
201211
)
202212
}
203213

AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import com.ichi2.anki.IntentHandler
3131
import com.ichi2.anki.R
3232
import com.ichi2.anki.common.annotations.LegacyNotifications
3333
import com.ichi2.anki.libanki.Decks
34+
import com.ichi2.anki.libanki.sched.Counts
3435
import com.ichi2.anki.preferences.PENDING_NOTIFICATIONS_ONLY
3536
import com.ichi2.anki.preferences.sharedPrefs
3637
import com.ichi2.anki.reviewreminders.ReviewReminder
@@ -108,8 +109,16 @@ class NotificationService : BroadcastReceiver() {
108109
}
109110
}
110111
val dueCardsTotal = dueCardsCount.count()
111-
if (dueCardsTotal < reviewReminder.cardTriggerThreshold.threshold) {
112-
Timber.d("Aborting notification due to threshold: $dueCardsTotal < ${reviewReminder.cardTriggerThreshold.threshold}")
112+
val consideredCardsCount =
113+
Counts().apply {
114+
if (reviewReminder.countNew) addNew(dueCardsCount.new)
115+
if (reviewReminder.countLrn) addLrn(dueCardsCount.lrn)
116+
if (reviewReminder.countRev) addRev(dueCardsCount.rev)
117+
}
118+
val consideredCardsTotal = consideredCardsCount.count()
119+
Timber.d("Due cards count: $dueCardsCount, Considered cards count: $consideredCardsCount")
120+
if (consideredCardsTotal < reviewReminder.cardTriggerThreshold.threshold) {
121+
Timber.d("Aborting notification due to threshold: $consideredCardsTotal < ${reviewReminder.cardTriggerThreshold.threshold}")
113122
return
114123
}
115124

AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,7 @@
134134
android:id="@+id/add_edit_reminder_card_threshold_section"
135135
android:layout_width="match_parent"
136136
android:layout_height="wrap_content"
137-
android:orientation="horizontal"
138-
tools:ignore="UselessParent">
137+
android:orientation="horizontal">
139138

140139
<LinearLayout
141140
android:id="@+id/add_edit_reminder_card_threshold_label_container"
@@ -175,6 +174,69 @@
175174

176175
</LinearLayout>
177176

177+
<LinearLayout
178+
android:id="@+id/add_edit_reminder_count_new_section"
179+
android:layout_width="match_parent"
180+
android:layout_height="wrap_content"
181+
android:orientation="horizontal">
182+
183+
<com.google.android.material.checkbox.MaterialCheckBox
184+
android:id="@+id/add_edit_reminder_count_new_checkbox"
185+
android:layout_width="wrap_content"
186+
android:layout_height="wrap_content" />
187+
188+
<TextView
189+
android:id="@+id/add_edit_reminder_count_new_label"
190+
android:layout_width="0dp"
191+
android:layout_height="wrap_content"
192+
android:layout_weight="1"
193+
android:text="Count new cards when checking card threshold"
194+
tools:ignore="HardcodedText" />
195+
196+
</LinearLayout>
197+
198+
<LinearLayout
199+
android:id="@+id/add_edit_reminder_count_lrn_section"
200+
android:layout_width="match_parent"
201+
android:layout_height="wrap_content"
202+
android:orientation="horizontal">
203+
204+
<com.google.android.material.checkbox.MaterialCheckBox
205+
android:id="@+id/add_edit_reminder_count_lrn_checkbox"
206+
android:layout_width="wrap_content"
207+
android:layout_height="wrap_content" />
208+
209+
<TextView
210+
android:id="@+id/add_edit_reminder_count_lrn_label"
211+
android:layout_width="0dp"
212+
android:layout_height="wrap_content"
213+
android:layout_weight="1"
214+
android:text="Count cards in learning when checking card threshold"
215+
tools:ignore="HardcodedText" />
216+
217+
</LinearLayout>
218+
219+
<LinearLayout
220+
android:id="@+id/add_edit_reminder_count_rev_section"
221+
android:layout_width="match_parent"
222+
android:layout_height="wrap_content"
223+
android:orientation="horizontal">
224+
225+
<com.google.android.material.checkbox.MaterialCheckBox
226+
android:id="@+id/add_edit_reminder_count_rev_checkbox"
227+
android:layout_width="wrap_content"
228+
android:layout_height="wrap_content" />
229+
230+
<TextView
231+
android:id="@+id/add_edit_reminder_count_rev_label"
232+
android:layout_width="0dp"
233+
android:layout_height="wrap_content"
234+
android:layout_weight="1"
235+
android:text="Count cards in review when checking card threshold"
236+
tools:ignore="HardcodedText" />
237+
238+
</LinearLayout>
239+
178240
</LinearLayout>
179241

180242
</LinearLayout>

0 commit comments

Comments
 (0)