Compare commits
5 Commits
b6bcd195b0
...
feat/edit-
| Author | SHA1 | Date | |
|---|---|---|---|
| c63dfddb88 | |||
| 974185f354 | |||
| 6aacdd9111 | |||
| 4f263d00fe | |||
| bda002684c |
@@ -97,32 +97,58 @@
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
||||
The three MIME types cover the common labels the same calendar
|
||||
data arrives under: iCalendar 2.0 (text/calendar), the older
|
||||
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
||||
mail apps emit — Android cross-products the scheme and mimeType
|
||||
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
<!-- Receive a .ics shared from another app. -->
|
||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||
to create a new event, the way the AOSP calendar accepts it:
|
||||
ACTION_INSERT on the events dir mime type, carrying the new
|
||||
event's fields as CalendarContract extras
|
||||
(MainActivity.insertFormOrNull, issue #30). -->
|
||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
||||
to the same prefilled create form. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Edit an existing event another app/assistant/widget points at:
|
||||
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
|
||||
AOSP fires it. Opens the occurrence in the edit form (not the
|
||||
read-only detail — that's the VIEW filter above). Matched by the
|
||||
provider's item MIME type, like the VIEW filter. The occurrence's
|
||||
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
|
||||
supplied (MainActivity.editEventKeyOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||
|
||||
@@ -70,6 +70,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||
// detail channel; consumed once by CalendarHost.
|
||||
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -95,6 +100,7 @@ class MainActivity : AppCompatActivity() {
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -145,6 +151,8 @@ class MainActivity : AppCompatActivity() {
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -183,6 +191,7 @@ class MainActivity : AppCompatActivity() {
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,13 +212,19 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
||||
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||
* The new event's fields ride as CalendarContract extras; anything omitted
|
||||
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
||||
* A prefilled new-event form from an external launch asking us to create an
|
||||
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||
* [buildInsertEventForm].
|
||||
*/
|
||||
private fun Intent.insertFormOrNull(): EventForm? {
|
||||
if (action != Intent.ACTION_INSERT) return null
|
||||
// ACTION_EDIT on an existing event routes to the edit form instead
|
||||
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
|
||||
val isCreate = action == Intent.ACTION_INSERT ||
|
||||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
|
||||
if (!isCreate) return null
|
||||
return buildInsertEventForm(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||
@@ -318,6 +333,31 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||
* an assistant, task app, or widget that wants to open the event for editing
|
||||
* rather than viewing). Opens it in the edit form. Reuses the same
|
||||
* occurrence-key channel as reminder/view taps; the caller passes the
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
||||
*/
|
||||
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_EDIT) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "events") return null
|
||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return longArrayOf(
|
||||
eventId,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
|
||||
@@ -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
|
||||
@@ -74,6 +75,8 @@ fun CalendarHost(
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -172,6 +175,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 +196,7 @@ fun CalendarHost(
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
@@ -207,6 +214,20 @@ fun CalendarHost(
|
||||
importForm = null
|
||||
}
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||
// without this it would open hidden underneath them. Same held-key pattern as
|
||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||
LaunchedEffect(requestedEditKey) {
|
||||
if (requestedEditKey != null) {
|
||||
dismissCoveringOverlays()
|
||||
heldEditKey = requestedEditKey
|
||||
editKey = requestedEditKey
|
||||
onEditKeyConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
@@ -435,6 +456,7 @@ fun CalendarHost(
|
||||
onClose = { importUri = null },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
},
|
||||
)
|
||||
@@ -443,6 +465,7 @@ fun CalendarHost(
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
initialForm = form,
|
||||
initialFormSource = importFormSource,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
)
|
||||
|
||||
@@ -37,6 +37,8 @@ fun RootScreen(
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -88,6 +90,8 @@ fun RootScreen(
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -50,6 +51,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 +109,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 +149,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 +288,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 +334,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,10 +349,60 @@ 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
|
||||
* form is open, so user edits survive configuration changes.
|
||||
* tapped occurrence's own times, like on the detail screen. An external
|
||||
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
||||
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
|
||||
* form loads the event's real times instead of the epoch (mirrors the
|
||||
* detail screen's #48 fallback). No-op while a form is open, so user edits
|
||||
* survive configuration changes.
|
||||
*/
|
||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
@@ -312,8 +416,12 @@ class EventEditViewModel @Inject constructor(
|
||||
return@launch
|
||||
}
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.start.toEpochMilliseconds()
|
||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.end.toEpochMilliseconds()
|
||||
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
||||
// Sections holding data must show even when not in the defaults.
|
||||
_revealed.value = snapshot.form.populatedFields()
|
||||
_form.value = snapshot.form
|
||||
@@ -329,6 +437,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