Compare commits

...

4 Commits

Author SHA1 Message Date
65bd6c4254 feat(intent): create events from external ACTION_INSERT launches (#30)
Register an intent-filter for ACTION_INSERT on the events dir mime type
(vnd.android.cursor.dir/event), the way the AOSP calendar accepts one, so
other apps and widgets (e.g. the Todo Agenda widget) can launch Calendula
to create a new event.

MainActivity.insertFormOrNull parses the standard CalendarContract extras
(EXTRA_EVENT_BEGIN_TIME/END_TIME/ALL_DAY, Events.TITLE/DESCRIPTION/
EVENT_LOCATION/RRULE) into a prefilled EventForm via the pure, unit-tested
buildInsertEventForm — omitted fields fall back to the same defaults the
in-app "new event" uses (next full hour, +1h). The form is routed through
the existing single-event prefill channel (RootScreen → CalendarHost →
the create form for review), with calendarId left null so it resolves to
the last-used / first-writable calendar. No new permission is needed
(WRITE_CALENDAR is already held), and the user still explicitly saves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:12:56 +02:00
2239c38ecc Merge pull request 'release: v2.13.0 — special dates, custom fonts, quick-switch, es/it translations' (!58) from release/v2.13.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea release / release (push) Successful in 11m16s
Renovate / renovate (push) Successful in 1m12s
Reviewed-on: #58
2026-07-03 14:47:42 +00:00
79ad70e0c0 release: cut v2.13.0 changelog and fastlane notes
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 9m3s
Move the accumulated [Unreleased] section under [2.13.0] — 2026-07-03,
adding the entries that were still missing: Spanish + Italian community
translations (thanks kikerw and corrent via Weblate), custom fonts (#19),
configurable quick-switch cycle + drawer order (#24), and the scrollable
event-editor calendar picker (#29). Regenerate fastlane/.../changelogs/
21300.txt via scripts/sync_changelog_to_fastlane.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:25:39 +02:00
f168021f51 release: bump version to 2.13.0 (21300)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:25:39 +02:00
9 changed files with 359 additions and 3 deletions

View File

@@ -5,9 +5,19 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [2.13.0] — 2026-07-03
### Added ### Added
- Calendula now speaks Spanish and Italian. Both arrived as community
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
— a **huge thank you** to
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
translation and to
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
one! Pick your language under Settings → Language or in Android's per-app
language settings. Strings added in this very release may still show in
English until the translations catch up. Want Calendula in your language?
Translating happens entirely in the browser — every contribution is welcome.
- See your contacts' birthdays and anniversaries in your calendar. A new, - See your contacts' birthdays and anniversaries in your calendar. A new,
**optional** feature (Settings → Contact special dates) mirrors your contacts' **optional** feature (Settings → Contact special dates) mirrors your contacts'
birthdays, anniversaries and other dates into local "Birthdays", birthdays, anniversaries and other dates into local "Birthdays",
@@ -33,6 +43,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
date number — now opens that day, and the "today" button snaps the grid back date number — now opens that day, and the "today" button snaps the grid back
to the current month in place. Thanks to @rgz46vic and @ptab for the to the current month in place. Thanks to @rgz46vic and @ptab for the
suggestions ([#18], [#20]). suggestions ([#18], [#20]).
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
and **Body font** pickers: keep the system default, choose a bundled face
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
face), or load any `.ttf`/`.otf` file from your device, independently per
role. Font size and colour stay with Android's accessibility scaling and the
app theme, as discussed on the issue. Thanks to @abrossimow for the
suggestion ([#19]).
- Choose what the view-switch button cycles through. Settings → Appearance now
lets you pick which views the top-right quick-switch button rotates between
and drag them into your preferred order; views you switch off stay reachable
from the navigation menu, which can be reordered the same way. Thanks to
@abrossimow for the suggestion ([#24]).
### Fixed ### Fixed
- Month widget arrows and "today" button work again. On release builds the - Month widget arrows and "today" button work again. On release builds the
@@ -49,6 +71,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
nothing applied. The edited occurrence is now stored correctly, so the change nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched lands on just that one event and leaves the rest of the series untouched
([#16]). ([#16]).
- Every calendar can be picked when creating an event. The event editor's
calendar picker was a fixed-height dialog, so with many calendars anything
past the first nine or so was simply unreachable. It is now a full-screen,
scrollable list grouped by account, with each calendar's colour shown.
Thanks to @dschuermann for the report ([#29]).
## [2.12.0] — 2026-06-28 ## [2.12.0] — 2026-06-28
@@ -779,4 +806,7 @@ automatically, with zero telemetry and no internet permission.
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16 [#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17 [#17]: https://codeberg.org/jlmakiola/calendula/issues/17
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18 [#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20 [#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21200 versionCode = 21300
versionName = "2.12.0" versionName = "2.13.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -112,6 +112,17 @@
<data android:mimeType="text/calendar" /> <data android:mimeType="text/calendar" />
</intent-filter> </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"). --> <!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.CalendarContract
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity 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.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour 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.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat 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.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@AndroidEntryPoint @AndroidEntryPoint
@@ -60,6 +64,11 @@ class MainActivity : AppCompatActivity() {
// by CalendarHost's import flow. // by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null) 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 // 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 // over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below. // crash-loop is handled out of band, before setContent — see below.
@@ -84,6 +93,7 @@ class MainActivity : AppCompatActivity() {
requestedDetailKey = intent.detailKeyOrNull() requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
@@ -132,6 +142,8 @@ class MainActivity : AppCompatActivity() {
onWidgetNavConsumed = { requestedNav = null }, onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null },
) )
} }
// A persistent corner marker so a debug build is never // A persistent corner marker so a debug build is never
@@ -169,6 +181,7 @@ class MainActivity : AppCompatActivity() {
intent.detailKeyOrNull()?.let { requestedDetailKey = it } intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it } intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = 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" } 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 * 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/ * "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/

View File

@@ -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)
}

View File

@@ -72,6 +72,8 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {}, onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
onInsertConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(), viewModel: CalendarHostViewModel = hiltViewModel(),
) { ) {
// Wait for the persisted default view before seeding the stack, so the app // Wait for the persisted default view before seeding the stack, so the app
@@ -176,6 +178,16 @@ fun CalendarHost(
onImportConsumed() 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 // Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on // requested destination (a widget/shortcut/QS-tile launch) is revealed on

View File

@@ -35,6 +35,8 @@ fun RootScreen(
onWidgetNavConsumed: () -> Unit = {}, onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
onInsertConsumed: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var hasPermission by remember { var hasPermission by remember {
@@ -84,6 +86,8 @@ fun RootScreen(
onWidgetNavConsumed = onWidgetNavConsumed, onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed, onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed,
) )
false -> ReminderOnboardingScreen( false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish, onFinished = reminderOnboarding::finish,

View File

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

View File

@@ -0,0 +1,70 @@
### Added
- Calendula now speaks Spanish and Italian. Both arrived as community
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
— a **huge thank you** to
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
translation and to
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
one! Pick your language under Settings → Language or in Android's per-app
language settings. Strings added in this very release may still show in
English until the translations catch up. Want Calendula in your language?
Translating happens entirely in the browser — every contribution is welcome.
- See your contacts' birthdays and anniversaries in your calendar. A new,
**optional** feature (Settings → Contact special dates) mirrors your contacts'
birthdays, anniversaries and other dates into local "Birthdays",
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
change. Each is a normal local calendar, so you set its colour, visibility and
reminders the usual way — new birthdays even start with a reminder a week before
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
This is the first feature to use the contacts permission: it is requested only
when you turn the feature on, everything stays on your device (Calendula has no
internet access), and your contacts are only ever read, never changed. Thanks
to @moonj for the suggestion ([#15]).
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
a reminder a week before *and* one on the day. The reminder pickers are now
multi-select; per-calendar overrides can still inherit the global default or
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
- Widget headers now open the app. Tapping the month/year title on the month
widget opens the app on the month view, and tapping the "Upcoming" title on
the agenda widget opens it on your default view — so there's a one-tap way
back into the app that lands where you'd expect, instead of only through a day
or event. On the month widget, tapping anywhere on a day — not just the small
date number — now opens that day, and the "today" button snaps the grid back
to the current month in place. Thanks to @rgz46vic and @ptab for the
suggestions ([#18], [#20]).
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
and **Body font** pickers: keep the system default, choose a bundled face
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
face), or load any `.ttf`/`.otf` file from your device, independently per
role. Font size and colour stay with Android's accessibility scaling and the
app theme, as discussed on the issue. Thanks to @abrossimow for the
suggestion ([#19]).
- Choose what the view-switch button cycles through. Settings → Appearance now
lets you pick which views the top-right quick-switch button rotates between
and drag them into your preferred order; views you switch off stay reachable
from the navigation menu, which can be reordered the same way. Thanks to
@abrossimow for the suggestion ([#24]).
### Fixed
- Month widget arrows and "today" button work again. On release builds the
prev/next-month arrows and the jump-to-today control on the month widget did
nothing when tapped — code shrinking had stripped the tap handlers behind
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
- Disabled calendars no longer notify. Reminders for events in a calendar you
have disabled (Settings → Calendars) are now suppressed instead of still
popping up — matching how a disabled calendar's events already stay hidden
everywhere else in the app ([#17]).
- Editing a single occurrence of a recurring event works again. Choosing **Only
this event** and saving a change to one event in a repeating series silently
did nothing — the change was rejected and the edit form simply reappeared with
nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched
([#16]).
- Every calendar can be picked when creating an event. The event editor's
calendar picker was a fixed-height dialog, so with many calendars anything
past the first nine or so was simply unreachable. It is now a full-screen,
scrollable list grouped by account, with each calendar's colour shown.
Thanks to @dschuermann for the report ([#29]).