release: cut v2.4.0 — per-event colors
All checks were successful
CI / ci (push) Successful in 9m20s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m3s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 9m22s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 7s

Optional per-event color in the event form. The read/render path already
resolved EVENT_COLOR with a calendar fallback; this adds the write side and
the picker.

- Palette-backed calendars (Google, some CalDAV) pick from the account's
  Colors (TYPE_EVENT) and write EVENT_COLOR_KEY, so the color round-trips
  through sync; local calendars write a raw EVENT_COLOR from the shared
  CALENDAR_COLOR_PALETTE. Never writes a raw color to a palette calendar.
- Swatch row + palette extracted to ui/common/ColorSwatchRow.kt (shared with
  the calendar editor). Switching calendars resets the choice (keys are
  account-scoped); a "Reset" action returns to the calendar color.
- New "Allow colors on unsupported calendars" setting (off by default)
  extends the raw path to no-palette synced calendars, with an honest
  "may not survive sync" warning on the picker and in Settings.
- Color flows through insert / dirty-checked update / occurrence-exception;
  mapper, form, and repository tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 08:55:16 +02:00
parent 210ddff8d8
commit b62f097392
28 changed files with 743 additions and 85 deletions

View File

@@ -7,6 +7,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers
@@ -400,4 +401,20 @@ class CalendarRepositoryImplTest {
assertThat(expected.message).contains("999")
}
}
@Test
fun `eventColorPalette delegates to the data source for the given calendar`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply {
eventColorPaletteResult = { id ->
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
assertThat(repo.eventColorPalette(7L))
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
assertThat(repo.eventColorPalette(8L)).isEmpty()
}
}

View File

@@ -20,6 +20,7 @@ class EventDetailMapperTest {
organizer: String? = "x@y",
rrule: String? = null,
eventColor: Any? = null,
eventColorKey: String? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
dtstart: Long = 1_000_000_000L,
dtend: Long = 1_000_003_600L,
@@ -49,6 +50,7 @@ class EventDetailMapperTest {
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
)
private fun attendeeReader(
@@ -99,6 +101,22 @@ class EventDetailMapperTest {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
.toDetail()
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
// No own colour: the edit form must see this as "inherits".
assertThat(detail.eventColor).isNull()
assertThat(detail.eventColorKey).isNull()
}
@Test
fun `own event color and key are surfaced apart from the resolved color`() {
val detail = detailReader(
eventColor = 0xFF33B679.toInt(),
eventColorKey = "5",
calendarColor = 0xFF112233.toInt(),
).toDetail()
// Resolved display colour is the event's own, not the calendar fallback.
assertThat(detail!!.instance.color).isEqualTo(0xFF33B679.toInt())
assertThat(detail.eventColor).isEqualTo(0xFF33B679.toInt())
assertThat(detail.eventColorKey).isEqualTo("5")
}
@Test

View File

@@ -218,4 +218,83 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
}
// --- per-event colour ---
@Test
fun `palette colour writes only the key, never a raw colour`() {
assertThat(eventColorColumns(colorKey = "5", color = 0xFF33B679.toInt()))
.containsExactly(CalendarContract.Events.EVENT_COLOR_KEY, "5")
}
@Test
fun `raw colour writes the colour and clears any key`() {
assertThat(eventColorColumns(colorKey = null, color = 0xFF8E24AA.toInt()))
.containsExactly(
CalendarContract.Events.EVENT_COLOR_KEY, null,
CalendarContract.Events.EVENT_COLOR, 0xFF8E24AA.toInt(),
)
}
@Test
fun `no colour clears both columns so the event inherits its calendar`() {
assertThat(eventColorColumns(colorKey = null, color = null))
.containsExactly(
CalendarContract.Events.EVENT_COLOR_KEY, null,
CalendarContract.Events.EVENT_COLOR, null,
)
}
@Test
fun `setting a palette colour on update writes just the key`() {
val original = form()
val values = update(original, original.copy(colorKey = "3", color = 0xFFF6BF26.toInt()))
assertThat(values).containsExactly(CalendarContract.Events.EVENT_COLOR_KEY, "3")
}
@Test
fun `setting a raw colour on update writes the colour and a null key`() {
val original = form()
val values = update(original, original.copy(color = 0xFF039BE5.toInt()))
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, 0xFF039BE5.toInt())
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
@Test
fun `clearing a colour on update writes explicit nulls`() {
val original = form().copy(color = 0xFFD50000.toInt())
val values = update(original, original.copy(color = null))
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
@Test
fun `unchanged colour writes no colour columns`() {
val original = form().copy(colorKey = "7", color = 0xFF3F51B5.toInt())
val values = update(original, original.copy(title = "Renamed"))
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `occurrence exception carries the palette key`() {
val values = buildOccurrenceExceptionValues(
form = form().copy(colorKey = "2", color = 0xFFE67C00.toInt()),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, "2")
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `occurrence exception with no colour clears both columns`() {
val values = buildOccurrenceExceptionValues(
form = form(),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
}

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -14,6 +15,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
/** Set to make the next write call throw. */
var writeError: Exception? = null
/** Id returned by the next [insertEvent]. */
@@ -45,6 +47,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
writeError?.let { throw it }

View File

@@ -115,6 +115,8 @@ class EventFormTest {
rowStart: Long = 0L,
rowEnd: Long = 0L,
attendees: List<Attendee> = emptyList(),
eventColor: Int? = null,
eventColorKey: String? = null,
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 1L,
@@ -134,6 +136,8 @@ class EventFormTest {
reminders = reminders,
availability = availability,
accessLevel = accessLevel,
eventColor = eventColor,
eventColorKey = eventColorKey,
)
@Test
@@ -227,6 +231,7 @@ class EventFormTest {
rrule = "FREQ=DAILY",
availability = Availability.Free,
accessLevel = AccessLevel.Private,
color = 0xFFD50000.toInt(),
)
assertThat(full.populatedFields()).containsExactly(
EventFormField.Location,
@@ -235,6 +240,33 @@ class EventFormTest {
EventFormField.Recurrence,
EventFormField.Availability,
EventFormField.Visibility,
EventFormField.Color,
)
}
@Test
fun `toEditForm carries a palette colour as key plus swatch`() {
val prefilled = detail(eventColor = 0xFF33B679.toInt(), eventColorKey = "5")
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.colorKey).isEqualTo("5")
assertThat(prefilled.color).isEqualTo(0xFF33B679.toInt())
assertThat(prefilled.populatedFields()).contains(EventFormField.Color)
}
@Test
fun `toEditForm carries a raw colour with no key`() {
val prefilled = detail(eventColor = 0xFF8E24AA.toInt())
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.colorKey).isNull()
assertThat(prefilled.color).isEqualTo(0xFF8E24AA.toInt())
}
@Test
fun `toEditForm leaves an inheriting event without a colour`() {
val prefilled = detail()
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.colorKey).isNull()
assertThat(prefilled.color).isNull()
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color)
}
}