-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(reminders): AddEditReminderDialog #19109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
david-allison
merged 7 commits into
ankidroid:main
from
ericli3690:ericli3690-review-reminders-add-edit-reminders-dialog-august
Oct 2, 2025
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
15faa11
feat(reminders): make reminders DeckSpinnerSelection handle showAllDecks
ericli3690 00fa3d9
feat(reminders): add TimePicker styling
ericli3690 2f66352
feat(reminders): getCurrentTime
ericli3690 a026323
feat(reminders): AddEditReminderDialog XML
ericli3690 b076665
feat(reminders): AddEditReminderDialog
ericli3690 7438c0f
feat(reminders): integrate AddEditReminderDialog into ScheduleReminders
ericli3690 c049c11
NF: remove lint suppression from adapter
ericli3690 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
349 changes: 349 additions & 0 deletions
349
AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.toFormattedString(requireContext()) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.