diff --git a/.gitea/workflows/translations.yaml b/.gitea/workflows/translations.yaml
index 0d2e8f8..c621258 100644
--- a/.gitea/workflows/translations.yaml
+++ b/.gitea/workflows/translations.yaml
@@ -3,13 +3,15 @@ name: Translations
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
# only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
+#
+# Runs on every PR (no path filter) so the required "Translations / check"
+# status is always reported — like the `ci` job. A path-filtered workflow is
+# skipped on unrelated PRs and never posts its status, which leaves that
+# required check pending forever and blocks the merge of any code-only PR into a
+# release/* branch. The check itself is cheap and simply passes when the
+# committed translations are consistent, so always running it costs nothing.
on:
pull_request:
- paths:
- - 'app/src/main/res/values*/strings.xml'
- - 'app/src/main/res/xml/locales_config.xml'
- - 'scripts/check_translations.py'
- - '.gitea/workflows/translations.yaml'
concurrency:
group: translations-${{ github.ref }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4762245..6c4de5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,81 @@ 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).
-## [Unreleased]
+## [2.15.0] — 2026-07-15
+
+### Added
+- Show raw calendar colours. Calendula normally softens each calendar and event
+ colour toward a theme-fitting pastel so harsh sync colours read well on both
+ light and dark; a new **Soften calendar colours** setting (Settings → Design,
+ on by default) lets you turn that off and paint the exact colours your calendar
+ source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
+ @leonp5 for the report ([#36]).
+- Readable titles on dark event colours. An event bar's title now shows in white
+ on a dark colour and near-black on a light one, chosen automatically from the
+ colour's brightness, so a deep blue or purple event is legible at a glance in
+ the busy Week and Month views instead of dark-on-dark. This applies whether or
+ not colours are softened. Thanks to @ptab for the suggestion ([#21]).
+- A custom snooze duration. The **Snooze duration** setting (Settings →
+ Notifications) gains a **Custom…** option next to the minute presets: pick any
+ amount and switch between minutes and hours, so a snoozed reminder comes back
+ after exactly the delay you want instead of only a preset one ([#40]).
+- Move an event to another calendar. When editing an existing event, the
+ calendar row is now tappable — pick a different calendar and saving moves the
+ event across, instead of having to delete it and recreate it elsewhere.
+ Recurring series move as a whole, keeping their individually-edited and
+ cancelled occurrences, and any reminders and guests come along too. A calendar
+ can't simply be reassigned underneath an event, so Calendula recreates it on
+ the target and removes the original — the same approach other calendar apps
+ take. Thanks to @prismplex for the suggestion ([#39]).
+- Open an event straight into the edit form from another app. Calendula already
+ answered the "new event" and "open this event" hand-offs from other apps and
+ widgets; it now also answers the "edit this event" one, so an assistant, task
+ app, or widget can send an existing event to Calendula and land on its edit
+ screen rather than the read-only details. A hand-off with no event attached
+ opens the same prefilled create form as "new event". Calendula also recognises
+ a couple more file labels the same calendar data arrives under (`.vcs`
+ vCalendar files and the `application/ics` type), so opening or sharing those
+ into Calendula works too.
+- Keep today at the top of the agenda. A new **Always show today** setting
+ (Settings → Agenda, on by default) anchors today as the first entry in both the
+ Agenda screen and its home-screen widget even once nothing is left today —
+ under today's header a "No more events today" note appears — so the first
+ events you see are clearly today's rather than a future day's. Turn it off to
+ keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
+- Duplicate an event. The event details now carry a **Duplicate** action that
+ opens the editor pre-filled with a copy of the event as a new, unsaved one, so
+ a one-off like a shift or an appointment can be recreated by just changing the
+ day and time instead of re-typing every field. The copy keeps the original's
+ time, and its title, location, notes, colour, guests and reminders come along;
+ it's saved as its own single event (any repeat is left off — add one in the
+ editor if you want it). Duplicate works from read-only calendars too, dropping
+ the copy into a writable one. Thanks to @internet-rando for the suggestion
+ ([#52]).
+- French and Polish, in early form. Calendula has started speaking French and
+ Polish, both contributed as community translations through
+ [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
+ They are partway there, so untranslated parts still show in English until they
+ fill out — you can already pick either under Settings → Language or in Android's
+ per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
+ (Polish) for getting them started; help finishing them is very welcome.
+
+### Fixed
+- Reminders for events on another day no longer read as if they were today. A
+ reminder fired ahead of time — say, the day before — used to show only the
+ event's time, making it look like it was happening now. The notification now
+ says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
+ week, or the date for anything further out. Thanks to @moonj for the report
+ ([#46]).
+- Agenda dates now read in your locale's format. The agenda's range bar and its
+ day headers used a fixed day-month-year layout — and the range span even mixed
+ two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
+ conventions. Dates across the agenda and its widget now use your locale's own
+ field order, matching the rest of the app. The range bar also no longer repeats
+ the range's name from the selector button beside it, showing just the dates.
+- Calendar gutters line up with the menu button. The Month view's week-number
+ column, and the Week and Day views' hour labels, sat a few pixels left of the
+ hamburger menu above them; they now line up with it. In Month view the day
+ cells also sit squarely under their weekday letters.
## [2.14.1] — 2026-07-13
@@ -908,11 +982,18 @@ automatically, with zero telemetry and no internet permission.
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
+[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
+[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
+[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
+[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
+[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
+[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
+[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4f82ef9..b058653 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 = 21401
- versionName = "2.14.1"
+ versionCode = 21500
+ versionName = "2.15.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 47866af..930ab70 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -97,32 +97,58 @@
-
+
-
-
+
+
+
+
+
-
+
+
+
+ (MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
+ dir mime is AOSP's "edit a new event" — i.e. create — so it maps
+ to the same prefilled create form. -->
+
+
+
+
+
+
+
+
+ Tomorrow
+ Yesterday
Month
@@ -252,6 +256,7 @@
Today
Tomorrow
You\'re all caught up
+ No more events today
Search
@@ -288,6 +293,8 @@
Default view
Dynamic colour
Requires Android 12 or newer
+ Soften calendar colours
+ Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.
Headings font
Body font
System default
@@ -318,6 +325,8 @@
How far ahead the Agenda screen lists events.
Agenda widget range
How far ahead the agenda home-screen widget lists events.
+ Always show today
+ Keep today at the top of the agenda and its widget, even once nothing is left today.
Range bar
Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session
Today
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index 0c7b98a..385bf1f 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -11,6 +11,8 @@
+
+
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
index ed8e728..14fa836 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
@@ -312,6 +312,46 @@ class CalendarRepositoryImplTest {
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
}
+ @Test
+ fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
+ val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
+ val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
+ val original = EventForm(
+ calendarId = 1L,
+ title = "Stand-up",
+ start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
+ end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
+ )
+ val updated = original.copy(calendarId = 3L)
+
+ val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated)
+
+ assertThat(newId).isEqualTo(77L)
+ assertThat(fake.movedEvents).containsExactly(
+ FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated),
+ )
+ }
+
+ @Test
+ fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
+ val fake = FakeCalendarDataSource().apply {
+ writeError = WriteFailedException("insert moved event into calendar id=3")
+ }
+ val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
+ val form = EventForm(
+ calendarId = 1L,
+ start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
+ end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
+ )
+
+ try {
+ repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L))
+ error("Expected WriteFailedException")
+ } catch (expected: WriteFailedException) {
+ assertThat(expected.message).contains("3")
+ }
+ }
+
@Test
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
index 7aa8d78..972b9d9 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt
@@ -409,4 +409,114 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
+
+ // --- buildMovedMasterValues (calendar move: verbatim series copy) ---
+
+ private fun masterSnapshot(
+ isAllDay: Boolean = false,
+ dtStartMillis: Long = 1_781_164_800_000L,
+ dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L,
+ duration: String? = null,
+ rrule: String? = null,
+ rdate: String? = null,
+ exdate: String? = null,
+ ) = MasterEventSnapshot(
+ title = "Standup",
+ isAllDay = isAllDay,
+ dtStartMillis = dtStartMillis,
+ dtEndMillis = dtEndMillis,
+ duration = duration,
+ rrule = rrule,
+ rdate = rdate,
+ exdate = exdate,
+ timezone = "Europe/Berlin",
+ availability = CalendarContract.Events.AVAILABILITY_BUSY,
+ accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
+ status = CalendarContract.Events.STATUS_CONFIRMED,
+ location = "Room 1",
+ description = "",
+ )
+
+ @Test
+ fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() {
+ val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula")
+ assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L)
+ assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula")
+ assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
+ assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L)
+ assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
+ assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
+ // Empty description clears explicitly; a raw/keyed colour is never copied
+ // (may be invalid on the target account — the copy inherits its colour).
+ assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
+ assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
+ assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
+ }
+
+ @Test
+ fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() {
+ val values = buildMovedMasterValues(
+ masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"),
+ targetCalendarId = 9L,
+ uid = "u@calendula",
+ )
+ assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
+ assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
+ assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z")
+ // Recurring rows never carry DTEND (the provider's invariant).
+ assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
+ }
+
+ @Test
+ fun `moved series without a stored duration derives it from DTEND`() {
+ val values = buildMovedMasterValues(
+ masterSnapshot(duration = null, rrule = "FREQ=DAILY"),
+ targetCalendarId = 9L,
+ uid = "u@calendula",
+ )
+ assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
+ }
+
+ // --- buildCopiedExceptionValues (calendar move: exception replay) ---
+
+ private fun exceptionSnapshot(
+ isCancelled: Boolean = false,
+ duration: String? = null,
+ // Occurrence starts one hour into the day and runs 30 minutes.
+ dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L,
+ ) = ExceptionRowSnapshot(
+ exceptionEventId = 42L,
+ originalInstanceMillis = 1_781_164_800_000L,
+ isCancelled = isCancelled,
+ status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED,
+ title = "Moved occurrence",
+ isAllDay = false,
+ dtStartMillis = 1_781_164_800_000L + 3_600_000L,
+ dtEndMillis = dtEndMillis,
+ duration = duration,
+ timezone = "Europe/Berlin",
+ availability = CalendarContract.Events.AVAILABILITY_BUSY,
+ accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
+ location = "",
+ description = "Notes",
+ )
+
+ @Test
+ fun `copied modified occurrence carries the original instance and DURATION not DTEND`() {
+ val values = buildCopiedExceptionValues(exceptionSnapshot())
+ assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
+ .isEqualTo(1_781_164_800_000L)
+ assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence")
+ assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L)
+ // 30-minute occurrence derived from DTEND, written as DURATION only.
+ assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S")
+ assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
+ assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
+ }
+
+ @Test
+ fun `copied modified occurrence keeps a stored duration verbatim`() {
+ val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
+ assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
+ }
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
index d62ddae..dce1a32 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
@@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource {
val insertedForms = mutableListOf()
val updatedEvents = mutableListOf>()
+ data class MovedEvent(
+ val eventId: Long,
+ val targetCalendarId: Long,
+ val original: EventForm,
+ val updated: EventForm,
+ )
+ val movedEvents = mutableListOf()
val updatedOccurrences = mutableListOf>()
val updatedFromOccurrences = mutableListOf>()
val deletedEventIds = mutableListOf()
@@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
allDayReminderTimes += allDayReminderTimeMinutes
}
+ override fun moveEvent(
+ eventId: Long,
+ targetCalendarId: Long,
+ original: EventForm,
+ updated: EventForm,
+ allDayReminderTimeMinutes: Int,
+ ): Long {
+ writeError?.let { throw it }
+ movedEvents += MovedEvent(eventId, targetCalendarId, original, updated)
+ allDayReminderTimes += allDayReminderTimeMinutes
+ return nextInsertId
+ }
+
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt
index 4d3f6c6..c8014a1 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
+import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
@@ -18,41 +19,110 @@ class ReminderTimeTextTest {
private fun utcMidnight(date: LocalDate): Long =
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
+ /** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */
+ private fun text(
+ beginMillis: Long,
+ endMillis: Long,
+ isAllDay: Boolean = false,
+ zone: ZoneId = berlin,
+ locale: Locale = Locale.GERMANY,
+ is24Hour: Boolean = true,
+ today: LocalDate? = null,
+ firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
+ ): String = reminderTimeText(
+ beginMillis = beginMillis,
+ endMillis = endMillis,
+ isAllDay = isAllDay,
+ zone = zone,
+ locale = locale,
+ is24Hour = is24Hour,
+ today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
+ firstDayOfWeek = firstDayOfWeek,
+ tomorrowLabel = "Tomorrow",
+ yesterdayLabel = "Yesterday",
+ )
+
@Test
- fun `timed event on one day shows just the time range`() {
- val text = reminderTimeText(
+ fun `timed event today shows just the time range`() {
+ val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
- isAllDay = false,
- zone = berlin,
- locale = Locale.GERMANY,
- is24Hour = true,
)
assertThat(text).isEqualTo("09:30 – 10:00")
}
@Test
fun `12-hour preference renders an am-pm time range`() {
- val text = reminderTimeText(
+ val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
- isAllDay = false,
- zone = berlin,
locale = Locale.US,
is24Hour = false,
)
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
}
+ @Test
+ fun `tomorrow's event is prefixed with the tomorrow label`() {
+ val text = text(
+ beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin),
+ endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin),
+ today = LocalDate.of(2026, 6, 11),
+ )
+ assertThat(text).isEqualTo("Tomorrow, 09:30 – 10:00")
+ }
+
+ @Test
+ fun `yesterday's event is prefixed with the yesterday label`() {
+ val text = text(
+ beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin),
+ endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin),
+ today = LocalDate.of(2026, 6, 11),
+ )
+ assertThat(text).isEqualTo("Yesterday, 09:30 – 10:00")
+ }
+
+ @Test
+ fun `an event later this week is prefixed with the short weekday`() {
+ // 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week.
+ val text = text(
+ beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
+ endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
+ today = LocalDate.of(2026, 6, 11),
+ )
+ assertThat(text).isEqualTo("So., 09:30 – 10:00")
+ }
+
+ @Test
+ fun `the week boundary honours the week-start setting`() {
+ // Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the
+ // Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week
+ // and must render as a date — the opposite of the Monday-start case above.
+ val text = text(
+ beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
+ endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
+ today = LocalDate.of(2026, 6, 11),
+ firstDayOfWeek = DayOfWeek.SUNDAY,
+ )
+ assertThat(text).isEqualTo("14.06.2026, 09:30 – 10:00")
+ }
+
+ @Test
+ fun `an event next week falls back to the exact date, not a weekday`() {
+ // 2026-06-15 is the following Monday — a weekday alone would be ambiguous.
+ val text = text(
+ beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin),
+ endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin),
+ today = LocalDate.of(2026, 6, 11),
+ )
+ assertThat(text).isEqualTo("15.06.2026, 09:30 – 10:00")
+ }
+
@Test
fun `timed event crossing midnight includes both dates`() {
- val text = reminderTimeText(
+ val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
- isAllDay = false,
- zone = berlin,
- locale = Locale.GERMANY,
- is24Hour = true,
)
assertThat(text).contains("11.06.2026")
assertThat(text).contains("12.06.2026")
@@ -61,28 +131,35 @@ class ReminderTimeTextTest {
@Test
fun `all-day single day shows one date, read in UTC`() {
- val text = reminderTimeText(
+ val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day.
- zone = berlin,
- locale = Locale.GERMANY,
- is24Hour = true,
+ today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
}
+ @Test
+ fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
+ val text = text(
+ beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
+ endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
+ isAllDay = true,
+ today = LocalDate.of(2026, 6, 11),
+ )
+ assertThat(text).isEqualTo("12.06.2026")
+ }
+
@Test
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
- val text = reminderTimeText(
+ val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
- zone = berlin,
- locale = Locale.GERMANY,
- is24Hour = true,
+ today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
}
@@ -90,13 +167,11 @@ class ReminderTimeTextTest {
@Test
fun `degenerate all-day range never renders an inverted span`() {
val day = utcMidnight(LocalDate.of(2026, 6, 11))
- val text = reminderTimeText(
+ val text = text(
beginMillis = day,
endMillis = day,
isAllDay = true,
- zone = berlin,
- locale = Locale.GERMANY,
- is24Hour = true,
+ today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt
new file mode 100644
index 0000000..968b3ce
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt
@@ -0,0 +1,39 @@
+package de.jeanlucmakiola.calendula.ui.agenda
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.datetime.LocalDate
+import org.junit.jupiter.api.Test
+
+/** Covers the agenda widget's "always show today" anchor logic (#35). */
+class AnchorTodayTest {
+
+ private val today = LocalDate(2026, 6, 17)
+ private val tomorrow = LocalDate(2026, 6, 18)
+
+ @Test
+ fun `disabled leaves the days untouched even when today is absent`() {
+ val days = listOf(AgendaDay(tomorrow, emptyList()))
+ assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days)
+ }
+
+ @Test
+ fun `enabled prepends an empty today when it is missing`() {
+ val days = listOf(AgendaDay(tomorrow, emptyList()))
+ val result = anchorTodayIfMissing(days, today, enabled = true)
+ assertThat(result).hasSize(2)
+ assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList()))
+ assertThat(result[1]).isEqualTo(days.first())
+ }
+
+ @Test
+ fun `enabled anchors today into an otherwise empty list`() {
+ val result = anchorTodayIfMissing(emptyList(), today, enabled = true)
+ assertThat(result).containsExactly(AgendaDay(today, emptyList()))
+ }
+
+ @Test
+ fun `enabled is a no-op when today already has its own day`() {
+ val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
+ assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt
new file mode 100644
index 0000000..51a9819
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt
@@ -0,0 +1,158 @@
+package de.jeanlucmakiola.calendula.ui.edit
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
+import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
+import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.calendula.domain.CalendarSource
+import de.jeanlucmakiola.calendula.domain.EventDetail
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.Dispatchers
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+import java.nio.file.Path
+import kotlin.time.Instant
+
+/**
+ * Focuses on the save-time branch that decides between an in-place update and a
+ * calendar *move* (copy+delete). The provider-level move itself is verified
+ * on-device; here the fake records which repository call the ViewModel chose.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class EventEditViewModelTest {
+
+ private val dispatcher = UnconfinedTestDispatcher()
+
+ @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
+ @AfterEach fun tearDown() = Dispatchers.resetMain()
+
+ private val beginMillis = 1_781_164_800_000L
+ private val endMillis = beginMillis + 3_600_000L
+
+ private fun cal(id: Long): CalendarSource = CalendarSource(
+ id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
+ color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
+ )
+
+ private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
+ instance = EventInstance(
+ instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
+ start = Instant.fromEpochMilliseconds(beginMillis),
+ end = Instant.fromEpochMilliseconds(endMillis),
+ isAllDay = false, color = 0xFF000000.toInt(), location = null,
+ ),
+ description = null, organizer = null, attendees = emptyList(), rrule = rrule,
+ )
+
+ // Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
+ // last-used calendar) completes under advanceUntilIdle instead of on a real
+ // IO thread the virtual clock can't observe.
+ private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
+ PreferenceDataStoreFactory.create(
+ scope = CoroutineScope(dispatcher),
+ produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
+ ),
+ )
+
+ private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
+ PreferenceDataStoreFactory.create(
+ scope = CoroutineScope(dispatcher),
+ produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
+ ),
+ )
+
+ private fun viewModel(
+ tempDir: Path,
+ fake: FakeCalendarDataSource,
+ ): EventEditViewModel {
+ val p = prefs(tempDir)
+ val s = settings(tempDir)
+ val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
+ return EventEditViewModel(repo, p, s, dispatcher)
+ }
+
+ /** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
+ private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
+
+ @Test
+ fun `changing the calendar routes the save through a move, not an update`(
+ @TempDir tempDir: Path,
+ ) = runTest(dispatcher) {
+ val fake = FakeCalendarDataSource().apply {
+ calendarsResult = listOf(cal(1L), cal(2L))
+ eventDetailResult = { detail(calendarId = 1L) }
+ nextInsertId = 99L
+ }
+ val vm = viewModel(tempDir, fake)
+ val job = activate(vm)
+
+ vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
+ vm.setCalendar(2L)
+ vm.save()
+ advanceUntilIdle()
+
+ assertThat(fake.movedEvents).hasSize(1)
+ assertThat(fake.movedEvents.single().eventId).isEqualTo(42L)
+ assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L)
+ assertThat(fake.updatedEvents).isEmpty()
+ assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
+ job.cancel()
+ }
+
+ @Test
+ fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) {
+ val fake = FakeCalendarDataSource().apply {
+ calendarsResult = listOf(cal(1L), cal(2L))
+ eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
+ }
+ val vm = viewModel(tempDir, fake)
+ val job = activate(vm)
+
+ vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
+ vm.setCalendar(2L)
+ vm.save()
+ advanceUntilIdle()
+
+ // No AwaitingScope park: a move is inherently whole-series.
+ assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
+ assertThat(fake.movedEvents).hasSize(1)
+ job.cancel()
+ }
+
+ @Test
+ fun `editing a recurring event without moving still asks for the scope`(
+ @TempDir tempDir: Path,
+ ) = runTest(dispatcher) {
+ val fake = FakeCalendarDataSource().apply {
+ calendarsResult = listOf(cal(1L), cal(2L))
+ eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
+ }
+ val vm = viewModel(tempDir, fake)
+ val job = activate(vm)
+
+ vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
+ vm.setTitle("Renamed")
+ vm.save()
+ advanceUntilIdle()
+
+ assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope)
+ assertThat(fake.movedEvents).isEmpty()
+ job.cancel()
+ }
+}
diff --git a/fastlane/metadata/android/en-US/changelogs/21500.txt b/fastlane/metadata/android/en-US/changelogs/21500.txt
new file mode 100644
index 0000000..b9d6ee0
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/21500.txt
@@ -0,0 +1,74 @@
+### Added
+- Show raw calendar colours. Calendula normally softens each calendar and event
+ colour toward a theme-fitting pastel so harsh sync colours read well on both
+ light and dark; a new **Soften calendar colours** setting (Settings → Design,
+ on by default) lets you turn that off and paint the exact colours your calendar
+ source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
+ @leonp5 for the report ([#36]).
+- Readable titles on dark event colours. An event bar's title now shows in white
+ on a dark colour and near-black on a light one, chosen automatically from the
+ colour's brightness, so a deep blue or purple event is legible at a glance in
+ the busy Week and Month views instead of dark-on-dark. This applies whether or
+ not colours are softened. Thanks to @ptab for the suggestion ([#21]).
+- A custom snooze duration. The **Snooze duration** setting (Settings →
+ Notifications) gains a **Custom…** option next to the minute presets: pick any
+ amount and switch between minutes and hours, so a snoozed reminder comes back
+ after exactly the delay you want instead of only a preset one ([#40]).
+- Move an event to another calendar. When editing an existing event, the
+ calendar row is now tappable — pick a different calendar and saving moves the
+ event across, instead of having to delete it and recreate it elsewhere.
+ Recurring series move as a whole, keeping their individually-edited and
+ cancelled occurrences, and any reminders and guests come along too. A calendar
+ can't simply be reassigned underneath an event, so Calendula recreates it on
+ the target and removes the original — the same approach other calendar apps
+ take. Thanks to @prismplex for the suggestion ([#39]).
+- Open an event straight into the edit form from another app. Calendula already
+ answered the "new event" and "open this event" hand-offs from other apps and
+ widgets; it now also answers the "edit this event" one, so an assistant, task
+ app, or widget can send an existing event to Calendula and land on its edit
+ screen rather than the read-only details. A hand-off with no event attached
+ opens the same prefilled create form as "new event". Calendula also recognises
+ a couple more file labels the same calendar data arrives under (`.vcs`
+ vCalendar files and the `application/ics` type), so opening or sharing those
+ into Calendula works too.
+- Keep today at the top of the agenda. A new **Always show today** setting
+ (Settings → Agenda, on by default) anchors today as the first entry in both the
+ Agenda screen and its home-screen widget even once nothing is left today —
+ under today's header a "No more events today" note appears — so the first
+ events you see are clearly today's rather than a future day's. Turn it off to
+ keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
+- Duplicate an event. The event details now carry a **Duplicate** action that
+ opens the editor pre-filled with a copy of the event as a new, unsaved one, so
+ a one-off like a shift or an appointment can be recreated by just changing the
+ day and time instead of re-typing every field. The copy keeps the original's
+ time, and its title, location, notes, colour, guests and reminders come along;
+ it's saved as its own single event (any repeat is left off — add one in the
+ editor if you want it). Duplicate works from read-only calendars too, dropping
+ the copy into a writable one. Thanks to @internet-rando for the suggestion
+ ([#52]).
+- French and Polish, in early form. Calendula has started speaking French and
+ Polish, both contributed as community translations through
+ [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
+ They are partway there, so untranslated parts still show in English until they
+ fill out — you can already pick either under Settings → Language or in Android's
+ per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
+ (Polish) for getting them started; help finishing them is very welcome.
+
+### Fixed
+- Reminders for events on another day no longer read as if they were today. A
+ reminder fired ahead of time — say, the day before — used to show only the
+ event's time, making it look like it was happening now. The notification now
+ says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
+ week, or the date for anything further out. Thanks to @moonj for the report
+ ([#46]).
+- Agenda dates now read in your locale's format. The agenda's range bar and its
+ day headers used a fixed day-month-year layout — and the range span even mixed
+ two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
+ conventions. Dates across the agenda and its widget now use your locale's own
+ field order, matching the rest of the app. The range bar also no longer repeats
+ the range's name from the selector button beside it, showing just the dates.
+- Calendar gutters line up with the menu button. The Month view's week-number
+ column, and the Week and Day views' hour labels, sat a few pixels left of the
+ hamburger menu above them; they now line up with it. In Month view the day
+ cells also sit squarely under their weekday letters.
+
diff --git a/floret-kit b/floret-kit
index 78c5fd1..2124227 160000
--- a/floret-kit
+++ b/floret-kit
@@ -1 +1 @@
-Subproject commit 78c5fd1fd44a063e6de67c6209af1701c69fb316
+Subproject commit 2124227a7f462b46b5833250bc26d2ae6c7e2cd0