From 6aacdd91113a6c8bf5c804bfd2e1bc9b05251217 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:57:38 +0200 Subject: [PATCH] feat(edit): offer default reminder on .ics import instead of auto-applying (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the two prefill paths that share openImported(): an ACTION_INSERT intent still auto-applies the settings default (it carries no reminder semantics), but a .ics file — which owns its reminders — no longer silently decides. It keeps the file's reminders and raises a one-time prompt ("This event was imported with N reminder(s) — apply your default?") so the user chooses. The prompt is skipped when there's no real choice: no default configured, or the file already carries exactly it. openImported() now takes an ImportSource; CalendarHost tags the overlay Insert vs File. Accepting swaps in the default and reveals the section; declining (or dismissing) keeps the file's own reminders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/CalendarHost.kt | 7 + .../calendula/ui/edit/EventEditScreen.kt | 58 ++++++- .../calendula/ui/edit/EventEditViewModel.kt | 153 ++++++++++++++---- app/src/main/res/values/strings.xml | 10 ++ 4 files changed, 193 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 436e87d..a70951b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen +import de.jeanlucmakiola.calendula.ui.edit.ImportSource import de.jeanlucmakiola.calendula.ui.imports.ImportScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen import de.jeanlucmakiola.calendula.ui.search.SearchScreen @@ -172,6 +173,9 @@ fun CalendarHost( // picker (many). A plain conditional overlay (no slide) — it's transient. var importUri by remember { mutableStateOf(null) } var importForm by remember { mutableStateOf(null) } + // Which channel filled [importForm]: an .ics file (prompt to apply the default + // reminder) or an ACTION_INSERT intent (apply it automatically) — #49. + var importFormSource by remember { mutableStateOf(ImportSource.File) } // A restore (in-app "Restore from .ics" button) always runs the full import // flow — picker + summary — even for a single-event file, because the intent // is "restore a backup", not "add this one event". An externally opened .ics @@ -190,6 +194,7 @@ fun CalendarHost( // reveals on top of whatever was open without extra dismissal. LaunchedEffect(requestedInsertForm) { if (requestedInsertForm != null) { + importFormSource = ImportSource.Insert importForm = requestedInsertForm onInsertConsumed() } @@ -435,6 +440,7 @@ fun CalendarHost( onClose = { importUri = null }, onOpenSingle = { form -> importUri = null + importFormSource = ImportSource.File importForm = form }, ) @@ -443,6 +449,7 @@ fun CalendarHost( EventEditScreen( initialDateIso = null, initialForm = form, + initialFormSource = importFormSource, onClose = { importForm = null }, onSaved = { importForm = null }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 1e13366..b83f48e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -89,6 +89,7 @@ import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle @@ -182,12 +183,14 @@ fun EventEditScreen( editKey: LongArray? = null, initialStartMinutes: Int? = null, initialForm: EventForm? = null, + initialFormSource: ImportSource = ImportSource.File, viewModel: EventEditViewModel = hiltViewModel(), ) { LaunchedEffect(initialDateIso, editKey, initialForm) { when { - // Single-event .ics open: the form arrives prefilled for review. - initialForm != null -> viewModel.openImported(initialForm) + // A prefilled open: a single-event .ics for review, or an external + // ACTION_INSERT intent. The source drives how reminders are seeded. + initialForm != null -> viewModel.openImported(initialForm, initialFormSource) editKey != null -> viewModel.openForEdit( eventId = editKey[0], beginMillis = editKey[1], @@ -202,6 +205,7 @@ fun EventEditScreen( } val state by viewModel.state.collectAsStateWithLifecycle() val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle() + val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle() // The form is intentionally forgotten on every close (cancel or save) so // the next open starts clean; it survives rotation because openNew / @@ -332,6 +336,56 @@ fun EventEditScreen( }, ) } + + // A .ics import respects the file's reminders, but offers to swap in the + // configured default rather than silently deciding for the user (#49). + importReminderPrompt?.let { prompt -> + ImportReminderPromptDialog( + currentReminderCount = prompt.currentReminderCount, + onApply = viewModel::applyImportedReminderDefault, + onKeep = viewModel::dismissImportedReminderPrompt, + ) + } +} + +/** + * Offer to apply the settings default reminder to an event opened from a `.ics` + * file. The file's own reminders are kept unless the user accepts. A plain + * two-choice confirmation, so an [AlertDialog] (not a full-screen picker). + */ +@Composable +private fun ImportReminderPromptDialog( + currentReminderCount: Int, + onApply: () -> Unit, + onKeep: () -> Unit, +) { + AlertDialog( + onDismissRequest = onKeep, + title = { Text(stringResource(R.string.import_reminder_prompt_title)) }, + text = { + Text( + if (currentReminderCount == 0) { + stringResource(R.string.import_reminder_prompt_body_none) + } else { + pluralStringResource( + R.plurals.import_reminder_prompt_body_existing, + currentReminderCount, + currentReminderCount, + ) + }, + ) + }, + confirmButton = { + TextButton(onClick = onApply) { + Text(stringResource(R.string.import_reminder_prompt_apply)) + } + }, + dismissButton = { + TextButton(onClick = onKeep) { + Text(stringResource(R.string.import_reminder_prompt_keep)) + } + }, + ) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index fc7dc31..0779385 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -50,6 +50,36 @@ import kotlin.time.Duration.Companion.hours import kotlin.time.Instant import javax.inject.Inject +/** + * Where a prefilled [EventEditViewModel.openImported] form came from — the two + * sources want different reminder handling (#49). + */ +enum class ImportSource { + /** + * An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps). + * It carries no reminders of its own, so the settings default is applied + * automatically, exactly like an in-app new event. + */ + Insert, + + /** + * A parsed single-event `.ics` file. Its own reminders are respected; the + * settings default is offered through [EventEditViewModel.importReminderPrompt] + * rather than silently applied or suppressed. + */ + File, +} + +/** + * A pending offer to swap an imported `.ics` event's reminders for the settings + * default (#49). [currentReminderCount] is what the file carried (0 or more); + * [defaultReminders] is what accepting would set. + */ +data class ImportReminderPrompt( + val currentReminderCount: Int, + val defaultReminders: List, +) + /** * Holds the event form being composed. The form's calendar id resolves to * (user pick > last used > first writable); the resolved value is what the UI @@ -78,10 +108,16 @@ class EventEditViewModel @Inject constructor( // freezes the auto-applied default: switching calendars no longer overwrites // their choice. Reset with the form. private val _remindersTouched = MutableStateFlow(false) + // A one-time offer, raised when a .ics import opens, to replace the file's + // reminders with the settings default (#49). Null while there's nothing to ask. + private val _importReminderPrompt = MutableStateFlow(null) /** True when the event to edit couldn't be loaded; the screen closes itself. */ val loadFailed: StateFlow = _loadFailed.asStateFlow() + /** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */ + val importReminderPrompt: StateFlow = _importReminderPrompt.asStateFlow() + /** * The event being edited plus everything the form saw at load time. * For recurring events the write scope is chosen at save time; the @@ -112,7 +148,17 @@ class EventEditViewModel @Inject constructor( val allDay: List, val timedOverrides: Map>, val allDayOverrides: Map>, - ) + ) { + /** The default reminders for an event on [calendarId] of the given kind. */ + fun resolveFor(calendarId: Long?, isAllDay: Boolean): List = resolveDefaultReminder( + timedGlobal = timed, + allDayGlobal = allDay, + timedOverrides = timedOverrides, + allDayOverrides = allDayOverrides, + calendarId = calendarId, + isAllDay = isAllDay, + ) + } private data class ExternalInputs( val writable: List, @@ -242,29 +288,37 @@ class EventEditViewModel @Inject constructor( /** * Seed a fresh event from a prefilled [form] — a parsed single-event `.ics` - * file or an external `ACTION_INSERT` intent (another app/widget creating an - * event, e.g. Google Maps' "add to calendar"; #30, #49). [EventForm.calendarId] - * is null so the calendar still resolves to the last-used/first-writable one. + * file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another + * app/widget creating an event, e.g. Google Maps' "add to calendar"; + * [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the + * calendar still resolves to the last-used/first-writable one. + * + * Reminders are handled per [source], because the two paths mean different + * things by "no reminders": + * - [ImportSource.Insert] carries no reminder semantics, so the settings + * default is applied automatically like an in-app new event (a form that + * somehow did carry reminders keeps them, frozen). + * - [ImportSource.File] owns its reminders, so they're frozen as-is; if a + * settings default is configured and differs, [importReminderPrompt] offers + * to swap it in rather than silently deciding for the user. * - * Reminders follow the source rather than the path: a form that carries its - * own (an `.ics` with VALARMs) freezes them as touched, so the settings - * default never overwrites what it specified; a form with none — every - * insert intent, and an `.ics` without VALARMs — falls back to the settings - * default exactly like [openNew], instead of the empty freeze suppressing it. * No-op when a form is already open, so the prefill survives configuration * changes. */ - fun openImported(form: EventForm) { + fun openImported(form: EventForm, source: ImportSource) { if (_form.value != null || _editTarget.value != null) return _revealed.value = form.populatedFields() _form.value = form - if (form.reminders.isNotEmpty()) { - // Source specified its own reminders — freeze so the default can't - // clobber them (and a later calendar/all-day switch won't re-apply). - _remindersTouched.value = true - } else { - // Nothing carried: inherit the settings default like a new event. - applyDefaultReminder() + when (source) { + ImportSource.Insert -> + if (form.reminders.isNotEmpty()) _remindersTouched.value = true + else applyDefaultReminder() + + ImportSource.File -> { + // Respect the file's own reminders; never silently overwrite them. + _remindersTouched.value = true + maybePromptImportedReminderDefault(form) + } } } @@ -279,26 +333,12 @@ class EventEditViewModel @Inject constructor( private fun applyDefaultReminder(calendarId: Long? = null) { if (_editTarget.value != null || _remindersTouched.value) return viewModelScope.launch { - val defaults = combine( - settingsPrefs.defaultReminderMinutes, - settingsPrefs.defaultAllDayReminderMinutes, - settingsPrefs.perCalendarReminderOverride, - settingsPrefs.perCalendarAllDayReminderOverride, - ) { timed, allDay, timedOv, allDayOv -> - ReminderDefaults(timed, allDay, timedOv, allDayOv) - }.first() + val defaults = reminderDefaults() val targetId = calendarId ?: resolvedCalendarId.first() // Re-check after suspending: bail if the form closed or the user edited. val form = _form.value ?: return@launch if (_editTarget.value != null || _remindersTouched.value) return@launch - val reminders = resolveDefaultReminder( - timedGlobal = defaults.timed, - allDayGlobal = defaults.allDay, - timedOverrides = defaults.timedOverrides, - allDayOverrides = defaults.allDayOverrides, - calendarId = targetId, - isAllDay = form.isAllDay, - ) + val reminders = defaults.resolveFor(targetId, form.isAllDay) _form.value = form.copy(reminders = reminders) // Surface the section so an auto-applied default is visible and // removable, even when Reminders isn't a default-shown field. @@ -308,6 +348,52 @@ class EventEditViewModel @Inject constructor( } } + /** Snapshot the four settings-default reminder flows into one value. */ + private suspend fun reminderDefaults(): ReminderDefaults = combine( + settingsPrefs.defaultReminderMinutes, + settingsPrefs.defaultAllDayReminderMinutes, + settingsPrefs.perCalendarReminderOverride, + settingsPrefs.perCalendarAllDayReminderOverride, + ) { timed, allDay, timedOv, allDayOv -> + ReminderDefaults(timed, allDay, timedOv, allDayOv) + }.first() + + /** + * A `.ics` import respects the file's reminders, but an event opened from a + * file often has none while the user still expects their configured default. + * Rather than silently deciding, raise a one-time offer to swap in the + * settings default — but only when there's a real choice: a default is + * configured and it isn't already exactly what the file carried. + */ + private fun maybePromptImportedReminderDefault(form: EventForm) { + viewModelScope.launch { + val targetId = resolvedCalendarId.first() + val default = reminderDefaults().resolveFor(targetId, form.isAllDay) + // Bail if the form closed or became an edit while we resolved. + val current = _form.value ?: return@launch + if (_editTarget.value != null) return@launch + if (default.isEmpty() || default == current.reminders) return@launch + _importReminderPrompt.value = ImportReminderPrompt( + currentReminderCount = current.reminders.size, + defaultReminders = default, + ) + } + } + + /** Accept the import prompt: replace the file's reminders with the default. */ + fun applyImportedReminderDefault() { + val prompt = _importReminderPrompt.value ?: return + _importReminderPrompt.value = null + // Already frozen as touched by openImported; this just swaps the values. + update { it.copy(reminders = prompt.defaultReminders) } + _revealed.value = _revealed.value + EventFormField.Reminders + } + + /** Decline the import prompt: keep the file's own reminders untouched. */ + fun dismissImportedReminderPrompt() { + _importReminderPrompt.value = null + } + /** * Load an existing event into the form. [beginMillis]/[endMillis] are the * tapped occurrence's own times, like on the detail screen. No-op while a @@ -342,6 +428,7 @@ class EventEditViewModel @Inject constructor( _editTarget.value = null _loadFailed.value = false _remindersTouched.value = false + _importReminderPrompt.value = null } /** Unfold one optional field, picked in the "more fields" dialog. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87dd845..ade055d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,6 +117,16 @@ Event deleted This event was deleted in the meantime, for example on another device. Your changes can no longer be saved. + + Apply your default reminder? + This event was imported without any reminder. + + This event was imported with %1$d reminder. + This event was imported with %1$d reminders. + + Apply default + Keep as-is + Does not repeat Custom