diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8ad7750..1139b8b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -112,6 +112,17 @@
+
+
+
+
+
+
+
(null)
+ // A prefilled new-event form from an external ACTION_INSERT launch (another
+ // app/widget asking us to create an event, issue #30). Consumed once by
+ // CalendarHost, which opens it in the create form for review.
+ private var requestedInsertForm by mutableStateOf(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.
@@ -84,6 +93,7 @@ class MainActivity : AppCompatActivity() {
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull()
+ requestedInsertForm = intent.insertFormOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
@@ -132,6 +142,8 @@ class MainActivity : AppCompatActivity() {
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
+ requestedInsertForm = requestedInsertForm,
+ onInsertConsumed = { requestedInsertForm = null },
)
}
// A persistent corner marker so a debug build is never
@@ -169,6 +181,7 @@ class MainActivity : AppCompatActivity() {
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
+ intent.insertFormOrNull()?.let { requestedInsertForm = it }
}
/**
@@ -188,6 +201,31 @@ class MainActivity : AppCompatActivity() {
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
+ /**
+ * 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].
+ */
+ private fun Intent.insertFormOrNull(): EventForm? {
+ if (action != Intent.ACTION_INSERT) return null
+ return buildInsertEventForm(
+ beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
+ endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
+ isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
+ title = getStringExtra(CalendarContract.Events.TITLE),
+ description = getStringExtra(CalendarContract.Events.DESCRIPTION),
+ location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
+ rrule = getStringExtra(CalendarContract.Events.RRULE),
+ zone = TimeZone.currentSystemDefault(),
+ now = Clock.System.now(),
+ )
+ }
+
+ /** A Long extra's value, or null when the extra is absent. */
+ private fun Intent.longExtraOrNull(key: String): Long? =
+ if (hasExtra(key)) getLongExtra(key, 0L) else null
+
/**
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt
new file mode 100644
index 0000000..059f301
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt
@@ -0,0 +1,76 @@
+package de.jeanlucmakiola.calendula.domain
+
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Instant
+
+/**
+ * Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue
+ * #30). External apps and widgets (e.g. the Todo Agenda widget) launch the
+ * calendar this way to create a new event, passing the fields as
+ * [android.provider.CalendarContract] extras. Any field the intent omits falls
+ * back to the same defaults the in-app "new event" uses — a timed start at the
+ * next full hour and a one-hour duration. [EventForm.calendarId] is left null so
+ * it resolves to the last-used / first-writable calendar, exactly like the
+ * `.ics` single-event and plain new-event paths.
+ *
+ * Pure (no Android types) so it is unit-testable; the intent parsing that reads
+ * the extras lives in `MainActivity.insertFormOrNull`.
+ */
+fun buildInsertEventForm(
+ beginMillis: Long?,
+ endMillis: Long?,
+ isAllDay: Boolean,
+ title: String?,
+ description: String?,
+ location: String?,
+ rrule: String?,
+ zone: TimeZone,
+ now: Instant,
+): EventForm {
+ val (start, end) = if (isAllDay) {
+ // All-day provider times are UTC midnights with an exclusive end; show
+ // the last covered day and keep placeholder wall-clock times in case the
+ // user switches the event to timed (mirrors EventDetail.toEditForm).
+ val startDate = beginMillis
+ ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
+ ?: now.toLocalDateTime(zone).date
+ val endDate = endMillis
+ ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
+ ?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
+ ?: startDate
+ LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
+ } else {
+ val startTime = beginMillis
+ ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
+ ?: nextFullHour(now, zone)
+ val endTime = endMillis
+ ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
+ ?.takeIf { it >= startTime }
+ ?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
+ startTime to endTime
+ }
+ return EventForm(
+ calendarId = null,
+ title = title.orEmpty(),
+ isAllDay = isAllDay,
+ start = start,
+ end = end,
+ location = location.orEmpty(),
+ description = description.orEmpty(),
+ // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
+ // some callers include.
+ rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
+ )
+}
+
+private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
+ val hourMillis = 3_600_000L
+ val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
+ return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
+}
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 612840e..e0ce981 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -72,6 +72,8 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
+ requestedInsertForm: EventForm? = null,
+ onInsertConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) {
// Wait for the persisted default view before seeding the stack, so the app
@@ -176,6 +178,16 @@ fun CalendarHost(
onImportConsumed()
}
}
+ // An external ACTION_INSERT launch (another app/widget creating an event,
+ // issue #30) arrives already prefilled — open it in the same create form the
+ // single-event .ics path uses. [importForm] is the topmost overlay, so it
+ // reveals on top of whatever was open without extra dismissal.
+ LaunchedEffect(requestedInsertForm) {
+ if (requestedInsertForm != null) {
+ importForm = requestedInsertForm
+ onInsertConsumed()
+ }
+ }
// Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
index 3e7a2f3..284953a 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
@@ -35,6 +35,8 @@ fun RootScreen(
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
+ requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
+ onInsertConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -84,6 +86,8 @@ fun RootScreen(
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
+ requestedInsertForm = requestedInsertForm,
+ onInsertConsumed = onInsertConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt
new file mode 100644
index 0000000..aaf9b60
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt
@@ -0,0 +1,115 @@
+package de.jeanlucmakiola.calendula.domain
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.toInstant
+import org.junit.jupiter.api.Test
+import kotlin.time.Instant
+
+class InsertEventFormTest {
+
+ private val zone = TimeZone.UTC
+
+ // A fixed "now" at 2026-01-15T10:20:00Z; the next full hour is 11:00.
+ private val now = LocalDateTime(2026, 1, 15, 10, 20).toInstant(zone)
+
+ private fun build(
+ beginMillis: Long? = null,
+ endMillis: Long? = null,
+ isAllDay: Boolean = false,
+ title: String? = null,
+ description: String? = null,
+ location: String? = null,
+ rrule: String? = null,
+ ): EventForm = buildInsertEventForm(
+ beginMillis = beginMillis,
+ endMillis = endMillis,
+ isAllDay = isAllDay,
+ title = title,
+ description = description,
+ location = location,
+ rrule = rrule,
+ zone = zone,
+ now = now,
+ )
+
+ private fun millis(dateTime: LocalDateTime): Long = dateTime.toInstant(zone).toEpochMilliseconds()
+
+ @Test
+ fun `timed event maps all provided fields`() {
+ val start = LocalDateTime(2026, 3, 1, 14, 30)
+ val end = LocalDateTime(2026, 3, 1, 15, 45)
+ val form = build(
+ beginMillis = millis(start),
+ endMillis = millis(end),
+ title = "Standup",
+ description = "Daily",
+ location = "Room 1",
+ rrule = "FREQ=DAILY",
+ )
+ assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
+ assertThat(form.isAllDay).isFalse()
+ assertThat(form.start).isEqualTo(start)
+ assertThat(form.end).isEqualTo(end)
+ assertThat(form.title).isEqualTo("Standup")
+ assertThat(form.description).isEqualTo("Daily")
+ assertThat(form.location).isEqualTo("Room 1")
+ assertThat(form.rrule).isEqualTo("FREQ=DAILY")
+ }
+
+ @Test
+ fun `missing times default to the next full hour and a one-hour duration`() {
+ val form = build(title = "Quick note")
+ assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
+ assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
+ assertThat(form.title).isEqualTo("Quick note")
+ }
+
+ @Test
+ fun `timed event with only a start gets a one-hour end`() {
+ val start = LocalDateTime(2026, 5, 2, 9, 0)
+ val form = build(beginMillis = millis(start))
+ assertThat(form.start).isEqualTo(start)
+ assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
+ }
+
+ @Test
+ fun `an end before the start is ignored and falls back to plus one hour`() {
+ val start = LocalDateTime(2026, 5, 2, 9, 0)
+ val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
+ val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
+ assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
+ }
+
+ @Test
+ fun `all-day event uses UTC dates and decrements the exclusive end`() {
+ val startMillis = LocalDate(2026, 1, 1).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ val endExclusive = LocalDate(2026, 1, 3).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ val form = build(beginMillis = startMillis, endMillis = endExclusive, isAllDay = true, title = "Trip")
+ assertThat(form.isAllDay).isTrue()
+ assertThat(form.start.date).isEqualTo(LocalDate(2026, 1, 1))
+ assertThat(form.end.date).isEqualTo(LocalDate(2026, 1, 2))
+ // Placeholder wall-clock times survive a switch back to a timed event.
+ assertThat(form.start.time).isEqualTo(LocalTime(9, 0))
+ assertThat(form.end.time).isEqualTo(LocalTime(10, 0))
+ }
+
+ @Test
+ fun `all-day event without an end is a single day`() {
+ val startMillis = LocalDate(2026, 6, 10).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
+ val form = build(beginMillis = startMillis, isAllDay = true)
+ assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 10))
+ assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 10))
+ }
+
+ @Test
+ fun `a leading RRULE prefix is stripped and a blank rule becomes null`() {
+ assertThat(build(rrule = "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY")
+ assertThat(build(rrule = " ").rrule).isNull()
+ assertThat(build(rrule = null).rrule).isNull()
+ }
+}