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() + } +}