diff --git a/CHANGELOG.md b/CHANGELOG.md
index eff4ed3..3526c95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.13.1] — 2026-07-06
+
+### Added
+- Create events from other apps and widgets. Calendula now registers the
+ standard "insert event" intent (`ACTION_INSERT` on the calendar events type),
+ so other apps and home-screen widgets — such as the Todo Agenda widget — can
+ hand off to Calendula to create a new event. It opens the new-event form
+ prefilled with whatever they passed (title, start/end time, all-day, location,
+ description, recurrence), and picks your last-used or first writable calendar.
+ Thanks to @dschuermann for the suggestion ([#30]).
+
+### Fixed
+- Some recurring events could not be opened. Events in a series that started
+ before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
+ — showed "Something went wrong" instead of opening, because their stored start
+ time is a negative value that was wrongly treated as invalid. They now open
+ normally and appear in search again. A related case (an event whose stored end
+ precedes its start) is now kept and openable instead of failing the same way.
+ Thanks to @dschuermann for the report ([#34]).
+- The time picker now follows your 24-hour setting. With Calendula set to
+ 24-hour time, the clock dial for choosing an event's start and end time still
+ showed AM/PM instead of a 24-hour dial; it now matches your setting (and the
+ same fix applies to the all-day reminder time in Settings). Thanks to
+ @abrossimow for the report ([#27]).
+
## [2.13.0] — 2026-07-03
### Added
@@ -809,4 +834,7 @@ automatically, with zero telemetry and no internet permission.
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
+[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
+[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
+[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6ff2520..38a3422 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
- versionCode = 21300
- versionName = "2.13.0"
+ versionCode = 21301
+ versionName = "2.13.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
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/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt
index 4a59358..e6de991 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt
@@ -23,26 +23,29 @@ internal fun ColumnReader.toEventDetailCore(
attendees: List,
reminders: List,
): EventDetail? {
- val begin = getLong(EventDetailProjection.IDX_DTSTART)
-
- if (begin < 0L) {
- Log.w(TAG, "Dropping event with negative dtstart=$begin")
+ // DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
+ // for yearly birthdays/anniversaries synced over CalDAV) is legitimately
+ // negative — only an *absent* DTSTART marks a malformed row worth dropping.
+ // Dropping negatives made every occurrence of such a series un-openable
+ // (the detail loads the ancient series-master DTSTART), see issue #34.
+ if (isNull(EventDetailProjection.IDX_DTSTART)) {
+ Log.w(TAG, "Dropping event with missing dtstart")
return null
}
+ val begin = getLong(EventDetailProjection.IDX_DTSTART)
// Recurring events store DURATION instead of DTEND, so the series row's
// DTEND is null. Keep the event (end == begin); callers that opened a
// specific occurrence supply the real per-occurrence times from
- // CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
+ // CalendarContract.Instances. A present-but-backwards DTEND is malformed,
+ // but dropping the row would make the event un-openable — the same trap as
+ // the pre-1970 DTSTART bug above (issue #34): it would surface as the
+ // generic error screen with no way to open the event and fix it. Clamp to a
+ // zero-length event instead (matching SearchMapper's coerceAtLeast).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin
} else {
- val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
- if (rawEnd < begin) {
- Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
- return null
- }
- rawEnd
+ getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
// Kept raw (no untitled fallback): the detail screen substitutes its own
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt
index 4c09781..f83733d 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt
@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
* of DTEND — reconstruct the end the same way the `.ics` export does.
*/
internal fun ColumnReader.toSearchResult(): EventInstance? {
+ // A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
+ // (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
+ // long-running birthdays/anniversaries still surface in search.
+ if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART)
- if (dtStart < 0L) return null
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
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/main/java/de/jeanlucmakiola/calendula/ui/common/TimePickerAlert.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimePickerAlert.kt
index d161728..3426433 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimePickerAlert.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimePickerAlert.kt
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
-import android.content.Context
-import android.content.res.Resources
-import android.provider.Settings
-import android.text.format.DateFormat
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalTime
@@ -27,10 +22,13 @@ fun TimePickerAlert(
onConfirm: (LocalTime) -> Unit,
onDismiss: () -> Unit,
) {
+ // Honour the app's own time-format preference (the value every time label
+ // reads), not the system/locale clock — otherwise an explicit 24h setting
+ // still showed an AM/PM dial (issue #27).
val state = rememberTimePickerState(
initialHour = initial.hour,
initialMinute = initial.minute,
- is24Hour = deviceUses24HourClock(LocalContext.current),
+ is24Hour = LocalUse24HourFormat.current,
)
AlertDialog(
onDismissRequest = onDismiss,
@@ -45,24 +43,3 @@ fun TimePickerAlert(
text = { TimePicker(state = state) },
)
}
-
-/**
- * Whether the clock should read 24-hour, matching the rest of the device.
- *
- * [DateFormat.is24HourFormat] resolves a "locale default" system setting against
- * the *app's* context locale — and this app applies a per-app language
- * (AppCompatDelegate), so an English UI on a German-region phone would wrongly
- * read 12-hour while the system clock shows 24-hour. So we honour an explicit
- * system 12/24 override, and otherwise fall back to the **device** locale
- * (Resources.getSystem), not the app's.
- */
-private fun deviceUses24HourClock(context: Context): Boolean =
- when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
- "24" -> true
- "12" -> false
- // 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
- else -> {
- val deviceLocale = Resources.getSystem().configuration.locales[0]
- !DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
- }
- }
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt
index 6ef9528..299d2fe 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt
@@ -22,8 +22,8 @@ class EventDetailMapperTest {
eventColor: Any? = null,
eventColorKey: String? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
- dtstart: Long = 1_000_000_000L,
- dtend: Long = 1_000_003_600L,
+ dtstart: Long? = 1_000_000_000L,
+ dtend: Long? = 1_000_003_600L,
allDay: Int = 0,
location: String? = "Berlin",
calendarId: Long = 7L,
@@ -120,9 +120,31 @@ class EventDetailMapperTest {
}
@Test
- fun `dtend before dtstart drops detail`() {
+ fun `dtend before dtstart is clamped to a zero-length event, not dropped`() {
+ // A backwards DTEND is malformed, but dropping it would make the event
+ // un-openable (the "Something went wrong" trap, same as issue #34); keep
+ // it as a zero-length event so the user can still open and fix it.
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
- assertThat(detail).isNull()
+ assertThat(detail).isNotNull()
+ assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(2000L)
+ assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(2000L)
+ }
+
+ @Test
+ fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
+ // A yearly birthday/anniversary anchored before the epoch has a
+ // legitimately negative UTC epoch-millis DTSTART; recurring rows carry
+ // no DTEND (they use DURATION), so it stays end == begin.
+ val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
+ val detail = detailReader(dtstart = begin, dtend = null).toDetail()
+ assertThat(detail).isNotNull()
+ assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
+ assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
+ }
+
+ @Test
+ fun `absent dtstart drops detail`() {
+ assertThat(detailReader(dtstart = null).toDetail()).isNull()
}
@Test
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt
new file mode 100644
index 0000000..9b1bb30
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt
@@ -0,0 +1,45 @@
+package de.jeanlucmakiola.calendula.data.calendar
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class SearchMapperTest {
+
+ private fun searchReader(
+ id: Long = 1L,
+ calendarId: Long = 7L,
+ title: String? = "Birthday",
+ dtstart: Long? = 1_000_000_000L,
+ dtend: Long? = null,
+ duration: String? = "P1D",
+ allDay: Int = 1,
+ eventColor: Any? = null,
+ calendarColor: Int = 0xFFAABBCC.toInt(),
+ location: String? = null,
+ ): MapColumnReader = MapColumnReader(
+ SearchProjection.IDX_ID to id,
+ SearchProjection.IDX_CALENDAR_ID to calendarId,
+ SearchProjection.IDX_TITLE to title,
+ SearchProjection.IDX_DTSTART to dtstart,
+ SearchProjection.IDX_DTEND to dtend,
+ SearchProjection.IDX_DURATION to duration,
+ SearchProjection.IDX_ALL_DAY to allDay,
+ SearchProjection.IDX_EVENT_COLOR to eventColor,
+ SearchProjection.IDX_CALENDAR_COLOR to calendarColor,
+ SearchProjection.IDX_LOCATION to location,
+ )
+
+ @Test
+ fun `pre-1970 negative dtstart still surfaces in search (issue #34)`() {
+ val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
+ val result = searchReader(dtstart = begin, dtend = null, duration = "P1D").toSearchResult()
+ assertThat(result).isNotNull()
+ assertThat(result!!.start.toEpochMilliseconds()).isEqualTo(begin)
+ assertThat(result.title).isEqualTo("Birthday")
+ }
+
+ @Test
+ fun `absent dtstart drops the search hit`() {
+ assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
+ }
+}
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()
+ }
+}
diff --git a/fastlane/metadata/android/en-US/changelogs/21301.txt b/fastlane/metadata/android/en-US/changelogs/21301.txt
new file mode 100644
index 0000000..b9dbb4e
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/21301.txt
@@ -0,0 +1,16 @@
+### Added
+- Create events from other apps and widgets. Calendula now registers the
+ standard "insert event" intent, so other apps and home-screen widgets (such as
+ the Todo Agenda widget) can hand off to Calendula to create a new event — it
+ opens the new-event form prefilled with whatever they passed (title, time,
+ location, description). Thanks to @dschuermann for the suggestion ([#30]).
+
+### Fixed
+- Some recurring events could not be opened. Events in a series that started
+ before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
+ — showed "Something went wrong" instead of opening. They now open normally, and
+ show up in search again. Thanks to @dschuermann for the report ([#34]).
+- The time picker now follows your 24-hour setting. With Calendula set to
+ 24-hour time, the dial for choosing an event's start and end time still showed
+ AM/PM; it now matches your setting. Thanks to @abrossimow for the report
+ ([#27]).