Merge pull request 'fix(edit): default reminder for ACTION_INSERT events + import prompt (Codeberg #49)' (!71) from fix/insert-intent-default-reminder into release/v2.14.1
Reviewed-on: #71
This commit was merged in pull request #71.
This commit is contained in:
@@ -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<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(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 },
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<ImportReminderPrompt?>(null)
|
||||
|
||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||
|
||||
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
||||
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _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<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
)
|
||||
) {
|
||||
/** The default reminders for an event on [calendarId] of the given kind. */
|
||||
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
||||
timedGlobal = timed,
|
||||
allDayGlobal = allDay,
|
||||
timedOverrides = timedOverrides,
|
||||
allDayOverrides = allDayOverrides,
|
||||
calendarId = calendarId,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
}
|
||||
|
||||
private data class ExternalInputs(
|
||||
val writable: List<CalendarSource>,
|
||||
@@ -241,18 +287,39 @@ 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 ([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.
|
||||
*
|
||||
* 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
|
||||
_remindersTouched.value = true
|
||||
_revealed.value = form.populatedFields()
|
||||
_form.value = form
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,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.
|
||||
@@ -295,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
|
||||
@@ -329,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. */
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
<string name="event_edit_gone_title">Event deleted</string>
|
||||
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
||||
|
||||
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
|
||||
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
|
||||
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">This event was imported with %1$d reminder.</item>
|
||||
<item quantity="other">This event was imported with %1$d reminders.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Apply default</string>
|
||||
<string name="import_reminder_prompt_keep">Keep as-is</string>
|
||||
|
||||
<!-- Event form — recurrence picker (v1.3) -->
|
||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||
<string name="event_edit_recurrence_custom">Custom</string>
|
||||
|
||||
Reference in New Issue
Block a user