Skip to content

Commit d33fbe4

Browse files
committed
Merge branch 'refs/heads/ericli3690-review-reminders-add-edit-reminders-dialog-august' into ericli3690-review-reminders-integrated-firing
2 parents 5d54e5d + 330c8b6 commit d33fbe4

File tree

8 files changed

+870
-3
lines changed

8 files changed

+870
-3
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/DeckSpinnerSelection.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ class DeckSpinnerSelection(
159159
decks.allNamesAndIds(includeFiltered = showFilteredDecks, skipEmptyDefault = true)
160160
}.toMutableList().let { decks ->
161161
dropDownDecks = decks
162-
val deckNames = decks.map { it.name }
162+
val deckNames = decks.map { it.name }.toMutableList()
163+
if (showAllDecks) deckNames.add(0, context.getString(R.string.card_browser_all_decks))
163164
val noteDeckAdapter: ArrayAdapter<String?> =
164165
object :
165166
ArrayAdapter<String?>(context, R.layout.multiline_spinner_item, deckNames as List<String?>) {
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)