From 4f263d00fe6616a9ebbc51ed532d437e42249498 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:46:51 +0200 Subject: [PATCH 1/2] fix(edit): apply default reminder to ACTION_INSERT events (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External ACTION_INSERT launches (e.g. Google Maps' "add to calendar", the Todo Agenda widget) share the single-event .ics prefill channel: CalendarHost routes requestedInsertForm as importForm, so EventEditScreen calls openImported(), which froze reminders as touched to respect a file's own VALARMs. But an insert intent carries no reminders, so the empty freeze just suppressed the configured settings default — the event opened (and saved) with no reminder. Make the freeze follow the source, not the path: a form that carries its own reminders (an .ics with VALARMs) still freezes them; a form with none (every insert intent, and an .ics without VALARMs) falls back to the settings default via applyDefaultReminder(), exactly like openNew(). An intent that did carry reminders still wins. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/edit/EventEditViewModel.kt | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) 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 e2da425..fc7dc31 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 @@ -241,18 +241,31 @@ class EventEditViewModel @Inject constructor( } /** - * Seed a fresh event from a parsed `.ics` file (the single-event "open into - * the create form" path). [form] already carries the file's fields; its - * [EventForm.calendarId] is null so the calendar still resolves to the - * last-used/first-writable one, and reminders are frozen as touched so the - * settings default never overwrites what the file specified. No-op when a - * form is already open, so the prefill survives configuration changes. + * 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. + * + * 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) { if (_form.value != null || _editTarget.value != null) return - _remindersTouched.value = true _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() + } } /** -- 2.49.1 From 6aacdd91113a6c8bf5c804bfd2e1bc9b05251217 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:57:38 +0200 Subject: [PATCH 2/2] 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 -- 2.49.1