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,