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
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user