Compare commits
1 Commits
v2.13.0
...
feat/inser
| Author | SHA1 | Date | |
|---|---|---|---|
| 65bd6c4254 |
@@ -112,6 +112,17 @@
|
||||
<data android:mimeType="text/calendar" />
|
||||
</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). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -25,6 +26,8 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -42,6 +45,7 @@ import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -60,6 +64,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(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<EventForm?>(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/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user