|
| 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