Skip to content

Commit feb20b7

Browse files
committed
feat(reminders): AddEditReminderDialog
GSoC 2025: Review Reminders - Creates AddEditReminderDialog. - Adds DialogMode, which represents whether the dialog is in adding or editing mode. - Created a ViewModel for the AddEditReminderDialog. A specific view model is created because this allows the dialog's state to persist across redraws, ex. if the theme changes, if the device rotates, etc. It also centralizes the data of the review reminder being edited in a single source of truth. - Sets submit, cancel, and delete actions for the dialog; this is the main way users can delete review reminders. Deletion is locked behind a confirmation box just in case the user accidentally clicks the button. - Results for the deck picker dropdown in the dialog are received in ScheduleReminders and sent to the AddEditReminderDialog via a FragmentResult. See the docstring of `onDeckSelected` in ScheduleReminders to see why this is done. - Filling in the values in the dialog fields and setting listeners for when the values change is pulled out into a separate methods for readability. - Adds `showTimePickerDialog`. Shows a TimePicker dialog for picking review reminder time via modern Material3 guidelines. Overrides `onConfigurationChanged` to properly handle device rotation, which for some reason is not handled by default by MaterialTimePicker. - An edited review reminder is passed back to ReviewRemindersDatabase as a FragmentResult, which is a simple way of passing information between fragments.
1 parent 47313db commit feb20b7

File tree

3 files changed

+503
-0
lines changed

3 files changed

+503
-0
lines changed
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
/*
2+
* Copyright (c) 2025 Eric Li <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.reviewreminders
18+
19+
import android.app.Dialog
20+
import android.content.res.Configuration
21+
import android.os.Bundle
22+
import android.os.Parcelable
23+
import android.text.format.DateFormat
24+
import android.view.View
25+
import android.widget.EditText
26+
import android.widget.ImageView
27+
import android.widget.LinearLayout
28+
import android.widget.Spinner
29+
import androidx.appcompat.app.AlertDialog
30+
import androidx.appcompat.app.AppCompatActivity
31+
import androidx.appcompat.widget.Toolbar
32+
import androidx.core.os.BundleCompat
33+
import androidx.core.view.isVisible
34+
import androidx.core.widget.doOnTextChanged
35+
import androidx.fragment.app.DialogFragment
36+
import androidx.fragment.app.setFragmentResult
37+
import androidx.fragment.app.setFragmentResultListener
38+
import androidx.fragment.app.viewModels
39+
import com.google.android.material.button.MaterialButton
40+
import com.google.android.material.textfield.TextInputLayout
41+
import com.google.android.material.timepicker.MaterialTimePicker
42+
import com.google.android.material.timepicker.TimeFormat
43+
import com.ichi2.anki.DeckSpinnerSelection
44+
import com.ichi2.anki.R
45+
import com.ichi2.anki.dialogs.ConfirmationDialog
46+
import com.ichi2.anki.launchCatchingTask
47+
import com.ichi2.anki.libanki.Consts
48+
import com.ichi2.anki.libanki.DeckId
49+
import com.ichi2.anki.model.SelectableDeck
50+
import com.ichi2.anki.snackbar.showSnackbar
51+
import com.ichi2.anki.utils.ext.showDialogFragment
52+
import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown
53+
import com.ichi2.utils.customView
54+
import com.ichi2.utils.negativeButton
55+
import com.ichi2.utils.neutralButton
56+
import com.ichi2.utils.positiveButton
57+
import kotlinx.parcelize.Parcelize
58+
import timber.log.Timber
59+
60+
class AddEditReminderDialog : DialogFragment() {
61+
/**
62+
* Possible states of this dialog.
63+
* In particular, whether this dialog will be used to add a new review reminder or edit an existing one.
64+
*/
65+
@Parcelize
66+
sealed class DialogMode : Parcelable {
67+
/**
68+
* Adding a new review reminder. Requires the editing scope of [ScheduleReminders] as an argument so that the dialog can
69+
* pick a default deck to add to (or, if the scope is global, so that the dialog can
70+
* show that the review reminder will default to being a global reminder).
71+
*/
72+
data class Add(
73+
val schedulerScope: ReviewReminderScope,
74+
) : DialogMode()
75+
76+
/**
77+
* Editing an existing review reminder. Requires the reminder being edited so that the
78+
* dialog's fields can be populated with its information.
79+
*/
80+
data class Edit(
81+
val reminderToBeEdited: ReviewReminder,
82+
) : DialogMode()
83+
}
84+
85+
private val viewModel: AddEditReminderDialogViewModel by viewModels()
86+
87+
private lateinit var contentView: View
88+
89+
/**
90+
* The mode of this dialog, retrieved from arguments and set by [getInstance].
91+
* @see DialogMode
92+
*/
93+
private val dialogMode: DialogMode by lazy {
94+
requireNotNull(
95+
BundleCompat.getParcelable(requireArguments(), DIALOG_MODE_ARGUMENTS_KEY, DialogMode::class.java),
96+
) {
97+
"Dialog mode cannot be null"
98+
}
99+
}
100+
101+
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
102+
super.onCreateDialog(savedInstanceState)
103+
contentView = layoutInflater.inflate(R.layout.add_edit_reminder_dialog, null)
104+
Timber.d("dialog mode: %s", dialogMode.toString())
105+
106+
val dialogBuilder =
107+
AlertDialog.Builder(requireActivity()).apply {
108+
customView(contentView)
109+
positiveButton(R.string.dialog_ok)
110+
neutralButton(R.string.dialog_cancel)
111+
112+
if (dialogMode is DialogMode.Edit) {
113+
negativeButton(R.string.dialog_positive_delete)
114+
}
115+
}
116+
val dialog = dialogBuilder.create()
117+
118+
// We cannot create onClickListeners by directly using the lambda argument of positiveButton / negativeButton
119+
// because setting the onClickListener that way makes the dialog auto-dismiss upon the lambda completing.
120+
// We may need to abort submission or deletion. Hence we manually set the click listener here and only
121+
// dismiss conditionally from within the click listener methods (see onSubmit and onDelete).
122+
dialog.setOnShowListener {
123+
val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
124+
val negativeButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
125+
positiveButton.setOnClickListener { onSubmit() }
126+
negativeButton?.setOnClickListener { onDelete() } // delete button does not exist in Add mode, hence null check
127+
}
128+
129+
Timber.d("Setting up fields")
130+
setUpToolbar()
131+
setUpTimeButton()
132+
setUpDeckSpinner()
133+
setUpAdvancedDropdown()
134+
setUpCardThresholdInput()
135+
136+
// For getting the result of the deck selection sub-dialog from ScheduleReminders
137+
// See ScheduleReminders.onDeckSelected for more information
138+
setFragmentResultListener(ScheduleReminders.DECK_SELECTION_RESULT_REQUEST_KEY) { _, bundle ->
139+
val selectedDeck =
140+
BundleCompat.getParcelable(
141+
bundle,
142+
ScheduleReminders.DECK_SELECTION_RESULT_REQUEST_KEY,
143+
SelectableDeck::class.java,
144+
)
145+
Timber.d("Received result from deck selection sub-dialog: %s", selectedDeck)
146+
val selectedDeckId: DeckId =
147+
when (selectedDeck) {
148+
is SelectableDeck.Deck -> selectedDeck.deckId
149+
is SelectableDeck.AllDecks -> DeckSpinnerSelection.ALL_DECKS_ID
150+
else -> Consts.DEFAULT_DECK_ID
151+
}
152+
viewModel.setDeckSelected(selectedDeckId)
153+
}
154+
155+
dialog.window?.let { resizeWhenSoftInputShown(it) }
156+
return dialog
157+
}
158+
159+
private fun setUpToolbar() {
160+
val toolbar = contentView.findViewById<Toolbar>(R.id.add_edit_reminder_toolbar)
161+
toolbar.title =
162+
getString(
163+
when (dialogMode) {
164+
is DialogMode.Add -> R.string.add_review_reminder
165+
is DialogMode.Edit -> R.string.edit_review_reminder
166+
},
167+
)
168+
}
169+
170+
private fun setUpTimeButton() {
171+
val timeButton = contentView.findViewById<MaterialButton>(R.id.add_edit_reminder_time_button)
172+
timeButton.setOnClickListener {
173+
Timber.i("Time button clicked")
174+
val time = viewModel.time.value ?: ReviewReminderTime.getCurrentTime()
175+
showTimePickerDialog(time.hour, time.minute)
176+
}
177+
viewModel.time.observe(this) { time ->
178+
timeButton.text = time.toString()
179+
}
180+
}
181+
182+
private fun setUpDeckSpinner() {
183+
val deckSpinner = contentView.findViewById<Spinner>(R.id.add_edit_reminder_deck_spinner)
184+
val deckSpinnerSelection =
185+
DeckSpinnerSelection(
186+
context = (activity as AppCompatActivity),
187+
spinner = deckSpinner,
188+
showAllDecks = true,
189+
alwaysShowDefault = true,
190+
showFilteredDecks = true,
191+
)
192+
launchCatchingTask {
193+
Timber.d("Setting up deck spinner")
194+
deckSpinnerSelection.initializeScheduleRemindersDeckSpinner()
195+
deckSpinnerSelection.selectDeckById(viewModel.deckSelected.value ?: Consts.DEFAULT_DECK_ID, setAsCurrentDeck = false)
196+
}
197+
}
198+
199+
private fun setUpAdvancedDropdown() {
200+
val advancedDropdown = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_advanced_dropdown)
201+
val advancedDropdownIcon = contentView.findViewById<ImageView>(R.id.add_edit_reminder_advanced_dropdown_icon)
202+
val advancedContent = contentView.findViewById<LinearLayout>(R.id.add_edit_reminder_advanced_content)
203+
204+
advancedDropdown.setOnClickListener {
205+
viewModel.toggleAdvancedSettingsOpen()
206+
}
207+
viewModel.advancedSettingsOpen.observe(this) { advancedSettingsOpen ->
208+
when (advancedSettingsOpen) {
209+
true -> {
210+
advancedContent.isVisible = true
211+
advancedDropdownIcon.setBackgroundResource(DROPDOWN_EXPANDED_CHEVRON)
212+
}
213+
false -> {
214+
advancedContent.isVisible = false
215+
advancedDropdownIcon.setBackgroundResource(DROPDOWN_COLLAPSED_CHEVRON)
216+
}
217+
}
218+
}
219+
}
220+
221+
private fun setUpCardThresholdInput() {
222+
val cardThresholdInputWrapper = contentView.findViewById<TextInputLayout>(R.id.add_edit_reminder_card_threshold_input_wrapper)
223+
val cardThresholdInput = contentView.findViewById<EditText>(R.id.add_edit_reminder_card_threshold_input)
224+
cardThresholdInput.setText(viewModel.cardTriggerThreshold.value.toString())
225+
cardThresholdInput.doOnTextChanged { text, _, _, _ ->
226+
val value: Int? = text.toString().toIntOrNull()
227+
cardThresholdInputWrapper.error =
228+
when {
229+
(value == null) -> "Please enter a whole number of cards"
230+
(value < 0) -> "The threshold must be at least 0"
231+
else -> null
232+
}
233+
viewModel.setCardTriggerThreshold(value ?: 0)
234+
}
235+
}
236+
237+
/**
238+
* Show the time picker dialog for selecting a time with a given hour and minute.
239+
* Does not automatically dismiss the old dialog.
240+
*/
241+
private fun showTimePickerDialog(
242+
hour: Int,
243+
minute: Int,
244+
) {
245+
val dialog =
246+
MaterialTimePicker
247+
.Builder()
248+
.setTheme(R.style.TimePickerStyle)
249+
.setTimeFormat(if (DateFormat.is24HourFormat(activity)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H)
250+
.setHour(hour)
251+
.setMinute(minute)
252+
.build()
253+
dialog.addOnPositiveButtonClickListener {
254+
viewModel.setTime(ReviewReminderTime(dialog.hour, dialog.minute))
255+
}
256+
dialog.show(parentFragmentManager, TIME_PICKER_TAG)
257+
}
258+
259+
/**
260+
* For some reason, the TimePicker dialog does not automatically redraw itself properly when the device rotates.
261+
* Thus, if the TimePicker dialog is active, we manually show a new copy and then dismiss the old one.
262+
* We need to show the new one before dismissing the old one to ensure there is no annoying flicker.
263+
*/
264+
override fun onConfigurationChanged(newConfig: Configuration) {
265+
super.onConfigurationChanged(newConfig)
266+
val previousDialog = parentFragmentManager.findFragmentByTag(TIME_PICKER_TAG) as? MaterialTimePicker
267+
previousDialog?.let {
268+
showTimePickerDialog(it.hour, it.minute)
269+
it.dismiss()
270+
}
271+
}
272+
273+
private fun onSubmit() {
274+
Timber.i("Submitted dialog")
275+
// Do nothing if numerical fields are invalid
276+
val cardThresholdInputWrapper = contentView.findViewById<TextInputLayout>(R.id.add_edit_reminder_card_threshold_input_wrapper)
277+
cardThresholdInputWrapper.error?.let {
278+
contentView.showSnackbar(R.string.something_wrong)
279+
return
280+
}
281+
282+
val reminderToBeReturned = viewModel.outputStateAsReminder()
283+
Timber.d("Reminder to be returned: %s", reminderToBeReturned)
284+
setFragmentResult(
285+
ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY,
286+
Bundle().apply {
287+
putParcelable(ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY, reminderToBeReturned)
288+
},
289+
)
290+
dismiss()
291+
}
292+
293+
private fun onDelete() {
294+
Timber.i("Selected delete reminder button")
295+
296+
val confirmationDialog = ConfirmationDialog()
297+
confirmationDialog.setArgs(
298+
"Delete this reminder?",
299+
"This action cannot be undone.",
300+
)
301+
confirmationDialog.setConfirm {
302+
setFragmentResult(
303+
ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY,
304+
Bundle().apply {
305+
putParcelable(ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY, null)
306+
},
307+
)
308+
dismiss()
309+
}
310+
311+
showDialogFragment(confirmationDialog)
312+
}
313+
314+
companion object {
315+
/**
316+
* Icon that shows next to the advanced settings section when the dropdown is open.
317+
*/
318+
private val DROPDOWN_EXPANDED_CHEVRON = R.drawable.ic_expand_more_black_24dp_xml
319+
320+
/**
321+
* Icon that shows next to the advanced settings section when the dropdown is closed.
322+
*/
323+
private val DROPDOWN_COLLAPSED_CHEVRON = R.drawable.ic_baseline_chevron_right_24
324+
325+
/**
326+
* Arguments key for the dialog mode to open this dialog in.
327+
* Public so that [AddEditReminderDialogViewModel] can also access it to populate its initial state.
328+
*
329+
* @see DialogMode
330+
*/
331+
const val DIALOG_MODE_ARGUMENTS_KEY = "dialog_mode"
332+
333+
/**
334+
* Unique fragment tag for the Material TimePicker shown for setting the time of a review reminder.
335+
*/
336+
private const val TIME_PICKER_TAG = "REMINDER_TIME_PICKER_DIALOG"
337+
338+
/**
339+
* Creates a new instance of this dialog with the given dialog mode.
340+
*/
341+
fun getInstance(dialogMode: DialogMode): AddEditReminderDialog =
342+
AddEditReminderDialog().apply {
343+
arguments =
344+
Bundle().apply {
345+
putParcelable(DIALOG_MODE_ARGUMENTS_KEY, dialogMode)
346+
}
347+
}
348+
}
349+
}

0 commit comments

Comments
 (0)