Skip to content
Closed
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,11 @@
android:enabled="true"
android:exported="false"
/>
<receiver
android:name=".services.AlarmManagerService"
android:enabled="true"
android:exported="false"
/>
<receiver
android:name=".services.BootService"
android:enabled="true"
Expand Down
5 changes: 4 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import com.ichi2.anki.preferences.SharedPreferencesProvider
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.servicelayer.DebugInfoService
import com.ichi2.anki.servicelayer.ThrowableFilterService
import com.ichi2.anki.services.AlarmManagerService
import com.ichi2.anki.services.NotificationService
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs
Expand Down Expand Up @@ -208,7 +209,9 @@ open class AnkiDroidApp :

if (Prefs.newReviewRemindersEnabled) {
Timber.i("Setting review reminder notifications if they have not already been set")
// TODO: GSoC 2025
applicationScope.launch {
AlarmManagerService.scheduleAllEnabledReviewReminderNotifications(applicationContext)
}
} else {
// Register for notifications
Timber.i("AnkiDroidApp: Starting Services")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ class DeckSpinnerSelection(
decks.allNamesAndIds(includeFiltered = showFilteredDecks, skipEmptyDefault = true)
}.toMutableList().let { decks ->
dropDownDecks = decks
val deckNames = decks.map { it.name }
val deckNames = decks.map { it.name }.toMutableList()
if (showAllDecks) deckNames.add(0, context.getString(R.string.card_browser_all_decks))
val noteDeckAdapter: ArrayAdapter<String?> =
object :
ArrayAdapter<String?>(context, R.layout.multiline_spinner_item, deckNames as List<String?>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
/*
* Copyright (c) 2025 Eric Li <ericli3690@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.reviewreminders

import android.app.Dialog
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.Spinner
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.os.BundleCompat
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 com.google.android.material.textfield.TextInputLayout
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import com.ichi2.anki.DeckSpinnerSelection
import com.ichi2.anki.R
import com.ichi2.anki.dialogs.ConfirmationDialog
import com.ichi2.anki.launchCatchingTask
import com.ichi2.anki.libanki.Consts
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.model.SelectableDeck
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown
import com.ichi2.utils.customView
import com.ichi2.utils.negativeButton
import com.ichi2.utils.neutralButton
import com.ichi2.utils.positiveButton
import kotlinx.parcelize.Parcelize
import timber.log.Timber

class AddEditReminderDialog : DialogFragment() {
/**
* Possible states of this dialog.
* In particular, whether this dialog will be used to add a new review reminder or edit an existing one.
*/
@Parcelize
sealed class DialogMode : Parcelable {
/**
* Adding a new review reminder. Requires the editing scope of [ScheduleReminders] as an argument so that the dialog can
* pick a default deck to add to (or, if the scope is global, so that the dialog can
* show that the review reminder will default to being a global reminder).
*/
data class Add(
val schedulerScope: ReviewReminderScope,
) : DialogMode()

/**
* Editing an existing review reminder. Requires the reminder being edited so that the
* dialog's fields can be populated with its information.
*/
data class Edit(
val reminderToBeEdited: ReviewReminder,
) : DialogMode()
}

private val viewModel: AddEditReminderDialogViewModel by viewModels()

private lateinit var contentView: View

/**
* The mode of this dialog, retrieved from arguments and set by [getInstance].
* @see DialogMode
*/
private val dialogMode: DialogMode by lazy {
requireNotNull(
BundleCompat.getParcelable(requireArguments(), DIALOG_MODE_ARGUMENTS_KEY, DialogMode::class.java),
) {
"Dialog mode cannot be null"
}
}

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

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

if (dialogMode is DialogMode.Edit) {
negativeButton(R.string.dialog_positive_delete)
}
}
val dialog = dialogBuilder.create()

// We cannot create onClickListeners by directly using the lambda argument of positiveButton / negativeButton
// because setting the onClickListener that way makes the dialog auto-dismiss upon the lambda completing.
// We may need to abort submission or deletion. Hence we manually set the click listener here and only
// dismiss conditionally from within the click listener methods (see onSubmit and onDelete).
dialog.setOnShowListener {
val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
val negativeButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
positiveButton.setOnClickListener { onSubmit() }
negativeButton?.setOnClickListener { onDelete() } // delete button does not exist in Add mode, hence null check
}

Timber.d("Setting up fields")
setUpToolbar()
setUpTimeButton()
setUpDeckSpinner()
setUpAdvancedDropdown()
setUpCardThresholdInput()

// For getting the result of the deck selection sub-dialog from ScheduleReminders
// See ScheduleReminders.onDeckSelected for more information
setFragmentResultListener(ScheduleReminders.DECK_SELECTION_RESULT_REQUEST_KEY) { _, bundle ->
val selectedDeck =
BundleCompat.getParcelable(
bundle,
ScheduleReminders.DECK_SELECTION_RESULT_REQUEST_KEY,
SelectableDeck::class.java,
)
Timber.d("Received result from deck selection sub-dialog: %s", selectedDeck)
val selectedDeckId: DeckId =
when (selectedDeck) {
is SelectableDeck.Deck -> selectedDeck.deckId
is SelectableDeck.AllDecks -> DeckSpinnerSelection.ALL_DECKS_ID
else -> Consts.DEFAULT_DECK_ID
}
viewModel.setDeckSelected(selectedDeckId)
}

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

private fun setUpToolbar() {
val toolbar = contentView.findViewById<Toolbar>(R.id.add_edit_reminder_toolbar)
toolbar.title =
getString(
when (dialogMode) {
is DialogMode.Add -> R.string.add_review_reminder
is DialogMode.Edit -> R.string.edit_review_reminder
},
)
}

private fun setUpTimeButton() {
val timeButton = contentView.findViewById<MaterialButton>(R.id.add_edit_reminder_time_button)
timeButton.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.toString()
}
}

private fun setUpDeckSpinner() {
val deckSpinner = contentView.findViewById<Spinner>(R.id.add_edit_reminder_deck_spinner)
val deckSpinnerSelection =
DeckSpinnerSelection(
context = (activity as AppCompatActivity),
spinner = deckSpinner,
showAllDecks = true,
alwaysShowDefault = true,
showFilteredDecks = true,
)
launchCatchingTask {
Timber.d("Setting up deck spinner")
deckSpinnerSelection.initializeScheduleRemindersDeckSpinner()
deckSpinnerSelection.selectDeckById(viewModel.deckSelected.value ?: Consts.DEFAULT_DECK_ID, setAsCurrentDeck = false)
}
}

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

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

private fun setUpCardThresholdInput() {
val cardThresholdInputWrapper = contentView.findViewById<TextInputLayout>(R.id.add_edit_reminder_card_threshold_input_wrapper)
val cardThresholdInput = contentView.findViewById<EditText>(R.id.add_edit_reminder_card_threshold_input)
cardThresholdInput.setText(viewModel.cardTriggerThreshold.value.toString())
cardThresholdInput.doOnTextChanged { text, _, _, _ ->
val value: Int? = text.toString().toIntOrNull()
cardThresholdInputWrapper.error =
when {
(value == null) -> "Please enter a whole number of cards"
(value < 0) -> "The threshold must be at least 0"
else -> null
}
viewModel.setCardTriggerThreshold(value ?: 0)
}
}

/**
* Show the time picker dialog for selecting a time with a given hour and minute.
* Does not automatically dismiss the old dialog.
*/
private fun showTimePickerDialog(
hour: Int,
minute: Int,
) {
val dialog =
MaterialTimePicker
.Builder()
.setTheme(R.style.TimePickerStyle)
.setTimeFormat(if (DateFormat.is24HourFormat(activity)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H)
.setHour(hour)
.setMinute(minute)
.build()
dialog.addOnPositiveButtonClickListener {
viewModel.setTime(ReviewReminderTime(dialog.hour, dialog.minute))
}
dialog.show(parentFragmentManager, TIME_PICKER_TAG)
}

/**
* For some reason, the TimePicker dialog does not automatically redraw itself properly when the device rotates.
* Thus, if the TimePicker dialog is active, we manually show a new copy and then dismiss the old one.
* We need to show the new one before dismissing the old one to ensure there is no annoying flicker.
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val previousDialog = parentFragmentManager.findFragmentByTag(TIME_PICKER_TAG) as? MaterialTimePicker
previousDialog?.let {
showTimePickerDialog(it.hour, it.minute)
it.dismiss()
}
}

private fun onSubmit() {
Timber.i("Submitted dialog")
// Do nothing if numerical fields are invalid
val cardThresholdInputWrapper = contentView.findViewById<TextInputLayout>(R.id.add_edit_reminder_card_threshold_input_wrapper)
cardThresholdInputWrapper.error?.let {
contentView.showSnackbar(R.string.something_wrong)
return
}

val reminderToBeReturned = viewModel.outputStateAsReminder()
Timber.d("Reminder to be returned: %s", reminderToBeReturned)
setFragmentResult(
ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY,
Bundle().apply {
putParcelable(ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY, reminderToBeReturned)
},
)
dismiss()
}

private fun onDelete() {
Timber.i("Selected delete reminder button")

val confirmationDialog = ConfirmationDialog()
confirmationDialog.setArgs(
"Delete this reminder?",
"This action cannot be undone.",
)
confirmationDialog.setConfirm {
setFragmentResult(
ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY,
Bundle().apply {
putParcelable(ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY, null)
},
)
dismiss()
}

showDialogFragment(confirmationDialog)
}

companion object {
/**
* Icon that shows next to the advanced settings section when the dropdown is open.
*/
private val DROPDOWN_EXPANDED_CHEVRON = R.drawable.ic_expand_more_black_24dp_xml

/**
* Icon that shows next to the advanced settings section when the dropdown is closed.
*/
private val DROPDOWN_COLLAPSED_CHEVRON = R.drawable.ic_baseline_chevron_right_24

/**
* Arguments key for the dialog mode to open this dialog in.
* Public so that [AddEditReminderDialogViewModel] can also access it to populate its initial state.
*
* @see DialogMode
*/
const val DIALOG_MODE_ARGUMENTS_KEY = "dialog_mode"

/**
* Unique fragment tag for the Material TimePicker shown for setting the time of a review reminder.
*/
private const val TIME_PICKER_TAG = "REMINDER_TIME_PICKER_DIALOG"

/**
* Creates a new instance of this dialog with the given dialog mode.
*/
fun getInstance(dialogMode: DialogMode): AddEditReminderDialog =
AddEditReminderDialog().apply {
arguments =
Bundle().apply {
putParcelable(DIALOG_MODE_ARGUMENTS_KEY, dialogMode)
}
}
}
}
Loading
Loading