From f4af44f18bcf1ad2a3348d3ec51a9e8d3b314072 Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 17 Jan 2026 17:25:57 -0800 Subject: [PATCH] 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. --- .../reviewreminders/AddEditReminderDialog.kt | 147 ++++-- .../AddEditReminderDialogViewModel.kt | 68 +++ .../anki/reviewreminders/ReviewReminder.kt | 30 ++ .../anki/services/NotificationService.kt | 7 +- ...ialog.xml => dialog_add_edit_reminder.xml} | 63 +++ AnkiDroid/src/main/res/values/03-dialogs.xml | 2 +- .../src/main/res/values/12-dont-translate.xml | 4 + .../anki/services/NotificationServiceTest.kt | 436 +++++++++--------- .../ichi2/anki/libanki/testutils/AnkiTest.kt | 27 ++ 9 files changed, 529 insertions(+), 255 deletions(-) rename AnkiDroid/src/main/res/layout/{add_edit_reminder_dialog.xml => dialog_add_edit_reminder.xml} (75%) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index a79d781aa0b3..cfd9492be346 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -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 @@ -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 @@ -89,7 +88,7 @@ class AddEditReminderDialog : DialogFragment() { private val viewModel: AddEditReminderDialogViewModel by viewModels() - private lateinit var contentView: View + private lateinit var binding: DialogAddEditReminderBinding /** * The mode of this dialog, retrieved from arguments and set by [getInstance]. @@ -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) @@ -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 @@ -156,8 +156,7 @@ class AddEditReminderDialog : DialogFragment() { else -> Consts.DEFAULT_DECK_ID } viewModel.setDeckSelected(selectedDeckId) - this.dialog?.findViewById(R.id.add_edit_reminder_deck_name)?.text = - selectedDeck?.getDisplayName(requireContext()) + binding.addEditReminderDeckName.text = selectedDeck?.getDisplayName(requireContext()) } dialog.window?.let { resizeWhenSoftInputShown(it) } @@ -165,8 +164,7 @@ class AddEditReminderDialog : DialogFragment() { } private fun setUpToolbar() { - val toolbar = contentView.findViewById(R.id.add_edit_reminder_toolbar) - toolbar.title = + binding.addEditReminderToolbar.title = getString( when (dialogMode) { is DialogMode.Add -> R.string.add_review_reminder @@ -176,25 +174,23 @@ class AddEditReminderDialog : DialogFragment() { } private fun setUpTimeButton() { - val timeButton = contentView.findViewById(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(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) } } @@ -231,34 +227,28 @@ class AddEditReminderDialog : DialogFragment() { } private fun setUpAdvancedDropdown() { - val advancedDropdown = contentView.findViewById(R.id.add_edit_reminder_advanced_dropdown) - val advancedDropdownIcon = contentView.findViewById(R.id.add_edit_reminder_advanced_dropdown_icon) - val advancedContent = contentView.findViewById(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(R.id.add_edit_reminder_card_threshold_input_wrapper) - val cardThresholdInput = contentView.findViewById(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" @@ -269,16 +259,79 @@ class AddEditReminderDialog : DialogFragment() { } private fun setUpOnlyNotifyIfNoReviewsCheckbox() { - val contentSection = contentView.findViewById(R.id.add_edit_reminder_only_notify_if_no_reviews_section) - val checkbox = contentView.findViewById(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, + ) + + /** + * 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") + 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 + } } } @@ -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(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 } @@ -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. */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt index 9a1c035f37fe..54c61d9bb3aa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogViewModel.kt @@ -98,6 +98,33 @@ class AddEditReminderDialogViewModel( ) val onlyNotifyIfNoReviews: LiveData = _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 = _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 = _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 = _countRev + private val _advancedSettingsOpen = MutableLiveData(INITIAL_ADVANCED_SETTINGS_OPEN) val advancedSettingsOpen: LiveData = _advancedSettingsOpen @@ -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) @@ -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 { @@ -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 } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt index 0bf200171768..7b8199c01ff5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewReminder.kt @@ -22,6 +22,7 @@ import android.text.format.DateFormat import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.libanki.sched.Counts import com.ichi2.anki.settings.Prefs import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -108,6 +109,31 @@ value class ReviewReminderCardTriggerThreshold( } } +/** + * A filter specifying which types of cards to count towards the [ReviewReminderCardTriggerThreshold]. + * + * @param countNew Whether new cards are counted when checking the [ReviewReminderCardTriggerThreshold]. + * @param countLrn Whether learning cards are counted when checking the [ReviewReminderCardTriggerThreshold]. + * @param countRev Whether review cards are counted when checking the [ReviewReminderCardTriggerThreshold]. + */ +@Serializable +@Parcelize +data class ReviewReminderThresholdFilter( + val countNew: Boolean = true, + val countLrn: Boolean = true, + val countRev: Boolean = true, +) : Parcelable { + /** + * Filters the given [inputCounts] according to this filter's settings and returns the resulting [Counts]. + */ + fun filterCounts(inputCounts: Counts): Counts = + Counts( + new = if (countNew) inputCounts.new else 0, + lrn = if (countLrn) inputCounts.lrn else 0, + rev = if (countRev) inputCounts.rev else 0, + ) +} + /** * An indicator of whether a review reminders feature is associated with every deck in the user's * collection or if it is associated with a single deck. For example, the [ScheduleReminders] fragment @@ -186,6 +212,7 @@ sealed class ReviewReminderScope : Parcelable { * @param profileID ID representing the profile which created this review reminder, as review reminders for * multiple profiles might be active simultaneously. * @param onlyNotifyIfNoReviews If true, only notify the user if this scope has not been reviewed today yet. + * @param thresholdFilter See [ReviewReminderThresholdFilter]. */ @Serializable @Parcelize @@ -198,6 +225,7 @@ data class ReviewReminder private constructor( var enabled: Boolean, val profileID: String, val onlyNotifyIfNoReviews: Boolean, + val thresholdFilter: ReviewReminderThresholdFilter, ) : Parcelable, ReviewReminderSchema { companion object { @@ -213,6 +241,7 @@ data class ReviewReminder private constructor( enabled: Boolean = true, profileID: String = "", onlyNotifyIfNoReviews: Boolean = false, + thresholdFilter: ReviewReminderThresholdFilter = ReviewReminderThresholdFilter(), ) = ReviewReminder( id = ReviewReminderId.getAndIncrementNextFreeReminderId(), time, @@ -221,6 +250,7 @@ data class ReviewReminder private constructor( enabled, profileID, onlyNotifyIfNoReviews, + thresholdFilter, ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt index ae094e1a43f8..5978f8845a7b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -112,8 +112,11 @@ class NotificationService : BroadcastReceiver() { } } val dueCardsTotal = dueCardsCount.count() - if (dueCardsTotal < reviewReminder.cardTriggerThreshold.threshold) { - Timber.i("Aborting notification due to threshold: $dueCardsTotal < ${reviewReminder.cardTriggerThreshold.threshold}") + val consideredCardsCount = reviewReminder.thresholdFilter.filterCounts(dueCardsCount) + val consideredCardsTotal = consideredCardsCount.count() + Timber.i("Due cards count: $dueCardsCount, Considered cards count: $consideredCardsCount") + if (consideredCardsTotal < reviewReminder.cardTriggerThreshold.threshold) { + Timber.i("Aborting notification due to threshold: $consideredCardsTotal < ${reviewReminder.cardTriggerThreshold.threshold}") return } diff --git a/AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml b/AnkiDroid/src/main/res/layout/dialog_add_edit_reminder.xml similarity index 75% rename from AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml rename to AnkiDroid/src/main/res/layout/dialog_add_edit_reminder.xml index c89ce0bec7dd..346ce14599d1 100644 --- a/AnkiDroid/src/main/res/layout/add_edit_reminder_dialog.xml +++ b/AnkiDroid/src/main/res/layout/dialog_add_edit_reminder.xml @@ -210,6 +210,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index a34ae286cb86..7eb174b14126 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -98,7 +98,7 @@ All cards - New + New Due diff --git a/AnkiDroid/src/main/res/values/12-dont-translate.xml b/AnkiDroid/src/main/res/values/12-dont-translate.xml index a0759b15c6a9..1b3fe21e28ea 100644 --- a/AnkiDroid/src/main/res/values/12-dont-translate.xml +++ b/AnkiDroid/src/main/res/values/12-dont-translate.xml @@ -43,5 +43,9 @@ If you want to use a string in your code that can't be translated, please use: Schedule reminders Review Reminders + Include %s cards for card threshold + New + Learning + To Review diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt index f0791f601f1f..e82315ca9681 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt @@ -28,13 +28,17 @@ import com.ichi2.anki.RobolectricTest import com.ichi2.anki.common.time.MockTime import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.libanki.QueueType import com.ichi2.anki.reviewreminders.ReviewReminder import com.ichi2.anki.reviewreminders.ReviewReminderCardTriggerThreshold import com.ichi2.anki.reviewreminders.ReviewReminderId -import com.ichi2.anki.reviewreminders.ReviewReminderScope +import com.ichi2.anki.reviewreminders.ReviewReminderScope.DeckSpecific +import com.ichi2.anki.reviewreminders.ReviewReminderScope.Global +import com.ichi2.anki.reviewreminders.ReviewReminderThresholdFilter import com.ichi2.anki.reviewreminders.ReviewReminderTime import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase import com.ichi2.anki.settings.Prefs +import io.mockk.CapturingSlot import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -80,92 +84,45 @@ class NotificationServiceTest : RobolectricTest() { ReviewRemindersDatabase.remindersSharedPrefs.edit { clear() } } - private fun createAndSaveDummyDeckSpecificReminder(did: DeckId): ReviewReminder { - val reviewReminder = createTestReminder(deckId = did, thresholdInt = 1) - ReviewRemindersDatabase.editRemindersForDeck(did) { mapOf(ReviewReminderId(0) to reviewReminder) } - return reviewReminder - } - - private fun createAndSaveDummyAppWideReminder(): ReviewReminder { - val reviewReminder = createTestReminder(thresholdInt = 1) - ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminder) } - return reviewReminder - } - - private fun triggerDummyReminderNotification(reviewReminder: ReviewReminder) { - val intent = - NotificationService.getIntent( - context, - reviewReminder, - NotificationService.NotificationServiceAction.ScheduleRecurringNotifications, - ) - NotificationService().onReceive(context, intent) - } - @Test fun `onReceive with less cards than card threshold should not fire notification but schedule next`() = runTest { - val did1 = addDeck("Deck", setAsSelected = true) - addNotes(2).forEach { - it.firstCard().update { did = did1 } - } + val did1 = addDeck("Deck", setAsSelected = true).withNote(count = 2) val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 3) val reviewReminderAppWide = createTestReminder(thresholdInt = 3) - ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) } - ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) } + ReviewRemindersDatabase.storeReminders(reviewReminderDeckSpecific, reviewReminderAppWide) triggerDummyReminderNotification(reviewReminderDeckSpecific) triggerDummyReminderNotification(reviewReminderAppWide) - verify(exactly = 0) { notificationManager.notify(any(), any(), any()) } - verify( - exactly = 1, - ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminderDeckSpecific) } - verify( - exactly = 1, - ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminderAppWide) } + verifyNoNotifsSent() + verifyNextNotifScheduled(reviewReminderDeckSpecific) + verifyNextNotifScheduled(reviewReminderAppWide) } @Test fun `onReceive with happy path for single deck should fire notification and schedule next`() = runTest { - val did1 = addDeck("Deck", setAsSelected = true) - addNotes(2).forEach { - it.firstCard().update { did = did1 } - } + val did1 = addDeck("Deck", setAsSelected = true).withNote(count = 2) val reviewReminder = createAndSaveDummyDeckSpecificReminder(did1) triggerDummyReminderNotification(reviewReminder) - verify( - exactly = 1, - ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminder.id.value, any()) } - verify( - exactly = 1, - ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder) } + verifyNotifSent(reviewReminder) + verifyNextNotifScheduled(reviewReminder) } @Test fun `onReceive with happy path for global reminder should fire notification and schedule next`() = runTest { - val did1 = addDeck("Deck1") - val did2 = addDeck("Deck2") - addNotes(2).forEach { - it.firstCard().update { did = did1 } - } - addNotes(2).forEach { - it.firstCard().update { did = did2 } - } + addDeck("Deck1").withNote(count = 2) + addDeck("Deck2").withNote(count = 2) val reviewReminder = createTestReminder(thresholdInt = 4) triggerDummyReminderNotification(reviewReminder) - verify( - exactly = 1, - ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminder.id.value, any()) } - verify( - exactly = 1, - ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder) } + verifyNotifSent(reviewReminder) + verifyNextNotifScheduled(reviewReminder) } @Test @@ -175,97 +132,68 @@ class NotificationServiceTest : RobolectricTest() { triggerDummyReminderNotification(reviewReminder) - verify(exactly = 0) { notificationManager.notify(any(), any(), any()) } - verify( - exactly = 1, - ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminder) } + verifyNoNotifsSent() + verifyNextNotifScheduled(reviewReminder) } @Test fun `onReceive with reviews today and onlyNotifyIfNoReviews is true should not fire notification`() = runTest { - val did1 = addDeck("Deck", setAsSelected = true) - addNotes(1).forEach { - it.firstCard().update { did = did1 } - } + val did1 = addDeck("Deck", setAsSelected = true).withNote() col.sched.answerCard(col.sched.card!!, CardAnswer.Rating.GOOD) val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 1, onlyNotifyIfNoReviews = true) val reviewReminderAppWide = createTestReminder(thresholdInt = 1, onlyNotifyIfNoReviews = true) - ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) } - ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) } + ReviewRemindersDatabase.storeReminders(reviewReminderDeckSpecific, reviewReminderAppWide) triggerDummyReminderNotification(reviewReminderDeckSpecific) triggerDummyReminderNotification(reviewReminderAppWide) - verify(exactly = 0) { notificationManager.notify(any(), any(), any()) } + + verifyNoNotifsSent() } @Test fun `onReceive with no reviews ever and onlyNotifyIfNoReviews is true should fire notification`() = runTest { - val did1 = addDeck("Deck", setAsSelected = true) - addNotes(1).forEach { - it.firstCard().update { did = did1 } - } + val did1 = addDeck("Deck", setAsSelected = true).withNote() val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 1, onlyNotifyIfNoReviews = true) val reviewReminderAppWide = createTestReminder(thresholdInt = 1, onlyNotifyIfNoReviews = true) - ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) } - ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) } + ReviewRemindersDatabase.storeReminders(reviewReminderDeckSpecific, reviewReminderAppWide) triggerDummyReminderNotification(reviewReminderDeckSpecific) triggerDummyReminderNotification(reviewReminderAppWide) - verify( - exactly = 1, - ) { - notificationManager.notify( - NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, - reviewReminderDeckSpecific.id.value, - any(), - ) - } - verify( - exactly = 1, - ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) } + + verifyNotifSent(reviewReminderDeckSpecific) + verifyNotifSent(reviewReminderAppWide) } @Test fun `onReceive with review yesterday but none today and onlyNotifyIfNoReviews is true should fire notification`() = runTest { TimeManager.resetWith(yesterday) // Wind back time and perform the review - val did1 = addDeck("Deck", setAsSelected = true) - addNotes(1).forEach { - it.firstCard().update { did = did1 } - } + val did1 = addDeck("Deck", setAsSelected = true).withNote() col.sched.answerCard(col.sched.card!!, CardAnswer.Rating.GOOD) TimeManager.resetWith(today) // Reset time to present - val reviewReminderDeckSpecific = createTestReminder(deckId = did1, thresholdInt = 1, onlyNotifyIfNoReviews = true) - val reviewReminderAppWide = createTestReminder(thresholdInt = 1, onlyNotifyIfNoReviews = true) - ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderDeckSpecific) } - ReviewRemindersDatabase.editAllAppWideReminders { mapOf(ReviewReminderId(1) to reviewReminderAppWide) } + val reviewReminderDeckSpecific = + createTestReminder(deckId = did1, thresholdInt = 1, onlyNotifyIfNoReviews = true) + val reviewReminderAppWide = + createTestReminder(thresholdInt = 1, onlyNotifyIfNoReviews = true) + ReviewRemindersDatabase.storeReminders( + reviewReminderDeckSpecific, + reviewReminderAppWide, + ) triggerDummyReminderNotification(reviewReminderDeckSpecific) triggerDummyReminderNotification(reviewReminderAppWide) - verify( - exactly = 1, - ) { - notificationManager.notify( - NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, - reviewReminderDeckSpecific.id.value, - any(), - ) - } - verify( - exactly = 1, - ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) } + + verifyNotifSent(reviewReminderDeckSpecific) + verifyNotifSent(reviewReminderAppWide) } @Test fun `onReceive with onlyNotifyIfNoReviews is false should always fire notification`() = runTest { - val did1 = addDeck("Deck", setAsSelected = true) - addNotes(1).forEach { - it.firstCard().update { did = did1 } - } + val did1 = addDeck("Deck", setAsSelected = true).withNote() val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1) val reviewReminderAppWide = createAndSaveDummyAppWideReminder() @@ -275,27 +203,14 @@ class NotificationServiceTest : RobolectricTest() { triggerDummyReminderNotification(reviewReminderDeckSpecific) triggerDummyReminderNotification(reviewReminderAppWide) - verify( - exactly = 2, - ) { - notificationManager.notify( - NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, - reviewReminderDeckSpecific.id.value, - any(), - ) - } - verify( - exactly = 2, - ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) } + verifyNotifSent(reviewReminderDeckSpecific, times = 2) + verifyNotifSent(reviewReminderAppWide, times = 2) } @Test fun `onReceive with blocked collection should not fire notification but schedule next`() = runTest { - val did1 = addDeck("Deck", setAsSelected = true) - addNotes(2).forEach { - it.firstCard().update { did = did1 } - } + val did1 = addDeck("Deck", setAsSelected = true).withNote(count = 2) val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1) val reviewReminderAppWide = createAndSaveDummyAppWideReminder() @@ -303,108 +218,124 @@ class NotificationServiceTest : RobolectricTest() { triggerDummyReminderNotification(reviewReminderDeckSpecific) triggerDummyReminderNotification(reviewReminderAppWide) - verify(exactly = 0) { notificationManager.notify(any(), any(), any()) } - verify( - exactly = 1, - ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminderDeckSpecific) } - verify( - exactly = 1, - ) { AlarmManagerService.scheduleReviewReminderNotification(context, reviewReminderAppWide) } + verifyNoNotifsSent() + verifyNextNotifScheduled(reviewReminderDeckSpecific) + verifyNextNotifScheduled(reviewReminderAppWide) } @Test fun `onReceive with snoozed notification should fire notification but not schedule next`() = runTest { - val did1 = addDeck("Deck", setAsSelected = true) - addNotes(2).forEach { - it.firstCard().update { did = did1 } - } + val did1 = addDeck("Deck", setAsSelected = true).withNote(count = 2) val reviewReminderDeckSpecific = createAndSaveDummyDeckSpecificReminder(did1) val reviewReminderAppWide = createAndSaveDummyAppWideReminder() - val intentDeckSpecific = - NotificationService.getIntent( - context, - reviewReminderDeckSpecific, - NotificationService.NotificationServiceAction.SnoozeNotification, - ) - val intentAppWide = - NotificationService.getIntent( - context, - reviewReminderAppWide, - NotificationService.NotificationServiceAction.SnoozeNotification, - ) + val intentDeckSpecific = reviewReminderDeckSpecific.getNotifIntent(NotifIntent.SNOOZE) + val intentAppWide = reviewReminderAppWide.getNotifIntent(NotifIntent.SNOOZE) NotificationService().onReceive(context, intentDeckSpecific) NotificationService().onReceive(context, intentAppWide) - verify( - exactly = 1, - ) { - notificationManager.notify( - NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, - reviewReminderDeckSpecific.id.value, - any(), - ) - } - verify( - exactly = 1, - ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reviewReminderAppWide.id.value, any()) } - verify(exactly = 0) { AlarmManagerService.scheduleReviewReminderNotification(context, any()) } + verifyNotifSent(reviewReminderDeckSpecific) + verifyNotifSent(reviewReminderAppWide) + verifyNextNotifNotScheduled() + } + + @Test + fun `onReceive with rev cards not counted and only rev cards present should not fire notification`() = + runTest { + val did1 = addDeck("Deck", setAsSelected = true).withNote(count = 2, queueType = QueueType.Rev) + val reviewReminder = createTestReminder(deckId = did1, countRev = false) + ReviewRemindersDatabase.storeReminders(reviewReminder) + + triggerDummyReminderNotification(reviewReminder) + + verifyNoNotifsSent() + } + + @Test + fun `onReceive with new cards not counted and only new cards present should not fire notification`() = + runTest { + val did1 = addDeck("Deck").withNote(count = 2) + val reviewReminder = createTestReminder(deckId = did1, countNew = false) + ReviewRemindersDatabase.storeReminders(reviewReminder) + + triggerDummyReminderNotification(reviewReminder) + + verifyNoNotifsSent() + } + + @Test + fun `onReceive with lrn cards not counted and only lrn cards present should not fire notification`() = + runTest { + val did1 = addDeck("Deck").withNote(count = 2, queueType = QueueType.Lrn) + val reviewReminder = createTestReminder(deckId = did1, countLrn = false) + ReviewRemindersDatabase.storeReminders(reviewReminder) + + triggerDummyReminderNotification(reviewReminder) + + verifyNoNotifsSent() + } + + @Test + fun `onReceive with all cards not counted and many cards present should not fire notification`() = + runTest { + val did1 = + addDeck("Deck") + .withNote(queueType = QueueType.New) + .withNote(queueType = QueueType.Lrn) + .withNote(queueType = QueueType.Rev) + val reviewReminder = createTestReminder(deckId = did1, countNew = false, countLrn = false, countRev = false) + ReviewRemindersDatabase.storeReminders(reviewReminder) + + triggerDummyReminderNotification(reviewReminder) + + verifyNoNotifsSent() + } + + @Test + fun `onReceive with new cards not counted but other kinds present should fire notification`() = + runTest { + val did1 = addDeck("Deck").withNote(queueType = QueueType.Rev).withNote(queueType = QueueType.Lrn) + val reviewReminder = createTestReminder(deckId = did1, countNew = false) + ReviewRemindersDatabase.storeReminders(reviewReminder) + + triggerDummyReminderNotification(reviewReminder) + + verifyNotifSent(reviewReminder) + } + + @Test + fun `onReceive with new cards not counted and not enough non new cards to trigger threshold should not fire notification`() = + runTest { + val did1 = addDeck("Deck").withNote(queueType = QueueType.New).withNote(queueType = QueueType.Lrn) + val reviewReminder = createTestReminder(deckId = did1, thresholdInt = 2, countNew = false) + ReviewRemindersDatabase.storeReminders(reviewReminder) + + triggerDummyReminderNotification(reviewReminder) + + verifyNoNotifsSent() } @Test fun `snooze actions of different notifications and different intervals should be different`() = runTest { - val did1 = addDeck("Deck1") - val did2 = addDeck("Deck2") - addNotes(2).forEach { - it.firstCard().update { did = did1 } - } - addNotes(2).forEach { - it.firstCard().update { did = did2 } - } + val did1 = addDeck("Deck1").withNote(count = 2) + val did2 = addDeck("Deck2").withNote(count = 2) val reviewReminderOne = createTestReminder(deckId = did1, thresholdInt = 1) val reviewReminderTwo = createTestReminder(deckId = did2, thresholdInt = 1) - ReviewRemindersDatabase.editRemindersForDeck(did1) { mapOf(ReviewReminderId(0) to reviewReminderOne) } - ReviewRemindersDatabase.editRemindersForDeck(did2) { mapOf(ReviewReminderId(1) to reviewReminderTwo) } + ReviewRemindersDatabase.storeReminders(reviewReminderOne, reviewReminderTwo) val slotOne = slot() val slotTwo = slot() - val intentOne = - NotificationService.getIntent( - context, - reviewReminderOne, - NotificationService.NotificationServiceAction.ScheduleRecurringNotifications, - ) + val intentOne = reviewReminderOne.getNotifIntent(NotifIntent.RECURRING) NotificationService().onReceive(context, intentOne) - val intentTwo = - NotificationService.getIntent( - context, - reviewReminderTwo, - NotificationService.NotificationServiceAction.ScheduleRecurringNotifications, - ) + val intentTwo = reviewReminderTwo.getNotifIntent(NotifIntent.RECURRING) NotificationService().onReceive(context, intentTwo) - verify( - exactly = 1, - ) { - notificationManager.notify( - NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, - reviewReminderOne.id.value, - capture(slotOne), - ) - } - verify( - exactly = 1, - ) { - notificationManager.notify( - NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, - reviewReminderTwo.id.value, - capture(slotTwo), - ) - } + verifyNotifSent(reviewReminderOne, slot = slotOne) + verifyNotifSent(reviewReminderTwo, slot = slotTwo) val snoozeIntents = setOf( @@ -416,21 +347,106 @@ class NotificationServiceTest : RobolectricTest() { assertThat(snoozeIntents.size, equalTo(4)) } + private fun createAndSaveDummyDeckSpecificReminder(did: DeckId): ReviewReminder { + val reviewReminder = createTestReminder(deckId = did, thresholdInt = 1) + ReviewRemindersDatabase.storeReminders(reviewReminder) + return reviewReminder + } + + private fun createAndSaveDummyAppWideReminder(): ReviewReminder { + val reviewReminder = createTestReminder(thresholdInt = 1) + ReviewRemindersDatabase.storeReminders(reviewReminder) + return reviewReminder + } + + private fun triggerDummyReminderNotification(reviewReminder: ReviewReminder) { + val intent = reviewReminder.getNotifIntent(NotifIntent.RECURRING) + NotificationService().onReceive(context, intent) + } + /** * Helper method for creating a review reminder to minimize verbosity in this file. - * - * @param deckId If specified, the reminder will be deck-specific to this deck ID. If null, it will be app-wide. - * @param thresholdInt The card trigger threshold as an integer. - * @param onlyNotifyIfNoReviews Whether the reminder should only notify if there are no reviews today. */ private fun createTestReminder( deckId: DeckId? = null, thresholdInt: Int = 1, onlyNotifyIfNoReviews: Boolean = false, + countNew: Boolean = true, + countLrn: Boolean = true, + countRev: Boolean = true, ) = ReviewReminder.createReviewReminder( time = ReviewReminderTime(hour = 12, minute = 0), cardTriggerThreshold = ReviewReminderCardTriggerThreshold(thresholdInt), - scope = if (deckId != null) ReviewReminderScope.DeckSpecific(deckId) else ReviewReminderScope.Global, + scope = if (deckId != null) DeckSpecific(deckId) else Global, onlyNotifyIfNoReviews = onlyNotifyIfNoReviews, + thresholdFilter = + ReviewReminderThresholdFilter( + countNew = countNew, + countLrn = countLrn, + countRev = countRev, + ), ) + + private fun ReviewRemindersDatabase.storeReminders(vararg reminders: ReviewReminder) { + reminders.forEachIndexed { i, reminder -> + when (reminder.scope) { + is DeckSpecific -> { + editRemindersForDeck(reminder.scope.did) { reminders -> + reminders + (ReviewReminderId(i) to reminder) + } + } + is Global -> { + editAllAppWideReminders { reminders -> + reminders + (ReviewReminderId(i) to reminder) + } + } + } + } + } + + private fun verifyNoNotifsSent() { + verify(exactly = 0) { notificationManager.notify(any(), any(), any()) } + } + + private fun verifyNotifSent( + reminder: ReviewReminder, + times: Int = 1, + slot: CapturingSlot? = null, + ) { + if (slot != null) { + verify( + exactly = times, + ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reminder.id.value, capture(slot)) } + } else { + verify( + exactly = times, + ) { notificationManager.notify(NotificationService.REVIEW_REMINDER_NOTIFICATION_TAG, reminder.id.value, any()) } + } + } + + private fun verifyNextNotifNotScheduled() { + verify(exactly = 0) { AlarmManagerService.scheduleReviewReminderNotification(any(), any()) } + } + + private fun verifyNextNotifScheduled(reminder: ReviewReminder) { + verify( + exactly = 1, + ) { AlarmManagerService.scheduleReviewReminderNotification(context, reminder) } + } + + /** + * Convenience enum class to minimize verbosity in test methods when using [getNotifIntent]. + */ + private enum class NotifIntent { + RECURRING, + SNOOZE, + } + + private fun ReviewReminder.getNotifIntent(action: NotifIntent) = + when (action) { + NotifIntent.RECURRING -> NotificationService.NotificationServiceAction.ScheduleRecurringNotifications + NotifIntent.SNOOZE -> NotificationService.NotificationServiceAction.SnoozeNotification + }.let { action -> + NotificationService.getIntent(context, this, action) + } } diff --git a/libanki/testutils/src/main/java/com/ichi2/anki/libanki/testutils/AnkiTest.kt b/libanki/testutils/src/main/java/com/ichi2/anki/libanki/testutils/AnkiTest.kt index 0aabe92cdfcf..8cc130fbb502 100644 --- a/libanki/testutils/src/main/java/com/ichi2/anki/libanki/testutils/AnkiTest.kt +++ b/libanki/testutils/src/main/java/com/ichi2/anki/libanki/testutils/AnkiTest.kt @@ -209,6 +209,33 @@ interface AnkiTest { /** Adds [count] notes in the same deck with the same front & back */ fun addNotes(count: Int): List = List(count) { addBasicNote() } + /** + * Adds [count] notes into the specified [queueType] of the provided deck. + */ + fun addNoteToDeck( + deckId: DeckId, + count: Int = 1, + queueType: QueueType = QueueType.New, + ) = addNotes(count).forEach { + it.firstCard().update { + did = deckId + queue = queueType + } + } + + /** + * Convenience method for chaining [addDeck] and [addNoteToDeck]. + * + * Usage: `val deckId = addDeck("My Deck").withNote(count = 5, queueType = QueueType.New)` + */ + fun DeckId.withNote( + count: Int = 1, + queueType: QueueType = QueueType.New, + ): DeckId = + this.apply { + addNoteToDeck(this, count = count, queueType = queueType) + } + fun Note.moveToDeck( deckName: String, createDeckIfMissing: Boolean = true,