diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 60ea747..09c7f4f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -166,8 +166,10 @@ unblock a later item. Order is a plan, not a contract — revisit after each lan 3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full grouped-list blueprint across Settings + calendars + drawer; see "v2.3" above)* -4. **Per-event color** *(next)* — reuses the calendar color picker/palette; closes the create/edit theme -5. Duplicate event — detail action → prefilled create form; near-free on the tap-to-create prefill infra +4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write + `EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw + `EVENT_COLOR`; off-by-default setting for no-palette synced calendars +5. **Duplicate event** *(next)* — detail action → prefilled create form; near-free on the tap-to-create prefill infra (Tier 2+ numbering below shifts accordingly; ranking unchanged.) diff --git a/.planning/STATE.md b/.planning/STATE.md index 96fc10d..4cf5742 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,6 +1,6 @@ # Calendula — Current State -*Last updated: 2026-06-16* +*Last updated: 2026-06-17* ## Status @@ -105,13 +105,24 @@ backlog is now organised by theme in `ROADMAP.md`. - Cards use `surfaceContainerHigh` for readable contrast against `surface`. - Donate button on the About card deferred (target still TBD). +- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in + the event form. Read/render 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` + (extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching + calendars resets the choice (a key is account-scoped). A settings toggle + ("Allow colors on unsupported calendars", off by default) extends the raw + path to synced calendars with no palette, with an honest "may not survive + sync" warning on the picker and in Settings. Color writes flow through + insert / dirty-checked update / occurrence-exception; mapper + form tests. + ## Next -1. Monitor the F-Droid build/publish for the v2.3.0 tag +1. Monitor the F-Droid build/publish for the v2.4.0 tag 2. Decide the "Locations & People" and "remote calendar create/edit" go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md` -3. **Per-event color** is next — reuses the color picker + palette plumbing - from local calendar management; finishes the create/edit theme. -4. Then agenda view (strategic, backs a future widget); jump-to-date and - duplicate event remain cheap follow-ups. Full ranked sequence in +3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then + agenda view (strategic, backs a future widget). Full ranked sequence in `ROADMAP.md` → "Near-term sequence". diff --git a/CHANGELOG.md b/CHANGELOG.md index d18b4cc..0cc9624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.4.0] — 2026-06-17 + +### Added +- Per-event colors: give a single event its own color, instead of always + inheriting its calendar's. Add the new "Color" field from "More fields" in + the event form. On calendars that publish their own color set — such as + Google — you pick from that calendar's palette, so the color is stored + with the event and shows correctly on every synced device. On local + calendars you pick from Calendula's palette. "Reset" returns an event to + its calendar's color +- A new "Allow colors on unsupported calendars" setting (New event form, + off by default) extends per-event colors to calendars that publish no + color set of their own (some CalDAV). Such a color is kept on the device + and may be dropped or overwritten on that calendar's next sync — a + limitation of those calendars, called out plainly in the setting and on + the color picker + ## [2.3.0] — 2026-06-16 ### Changed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index be99daa..674af39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // (e.g. v2.0.0 -> 20000). These committed values are the dev/local // default; keep them matching the latest released tag. See docs/RELEASING.md. - versionCode = 20300 - versionName = "2.3.0" + versionCode = 20400 + versionName = "2.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index b8dce6e..927b366 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -14,6 +14,7 @@ import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee 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 @@ -37,6 +38,15 @@ interface CalendarDataSource { fun instances(beginMillis: Long, endMillis: Long): List fun eventDetail(eventId: Long): EventDetail? + /** + * The event-colour palette the calendar's account publishes + * (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the + * account exposes no palette (most local calendars, some CalDAV) — the + * signal that a custom colour can only be written as a raw `EVENT_COLOR`, + * which a synced calendar may drop on its next sync. + */ + fun eventColorPalette(calendarId: Long): List + /** * Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns; * returns its `Calendars._ID`. Inserted through the sync-adapter URI so the @@ -215,6 +225,46 @@ class AndroidCalendarDataSource @Inject constructor( } } + override fun eventColorPalette(calendarId: Long): List { + val account = calendarAccount(calendarId) ?: return emptyList() + return resolver.query( + CalendarContract.Colors.CONTENT_URI, + arrayOf(CalendarContract.Colors.COLOR_KEY, CalendarContract.Colors.COLOR), + CalendarContract.Colors.ACCOUNT_NAME + " = ? AND " + + CalendarContract.Colors.ACCOUNT_TYPE + " = ? AND " + + CalendarContract.Colors.COLOR_TYPE + " = ?", + arrayOf( + account.name, + account.type, + CalendarContract.Colors.TYPE_EVENT.toString(), + ), + null, + )?.use { c -> + c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) } + } + ?.filter { it.key.isNotEmpty() } + ?.sortedBy { it.key } + ?: emptyList() + } + + /** The account a calendar belongs to, for scoping a `Colors` lookup. */ + private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query( + ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId), + arrayOf( + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.ACCOUNT_TYPE, + ), + null, null, null, + )?.use { c -> + if (c.moveToFirst()) { + CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty()) + } else { + null + } + } + + private data class CalendarAccount(val name: String, val type: String) + override fun insertEvent(form: EventForm): Long { val times = form.toWriteTimes(ZoneId.systemDefault()) val values = ContentValues().apply { @@ -240,6 +290,13 @@ class AndroidCalendarDataSource @Inject constructor( ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } form.description.trim().takeIf { it.isNotEmpty() } ?.let { put(CalendarContract.Events.DESCRIPTION, it) } + // A null colour just leaves both columns unset (the event inherits + // its calendar's colour), so only the key/raw cases are written. + when { + form.colorKey != null -> + put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey) + form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color) + } } val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values) ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 59cde96..04ec73e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -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 @@ -12,6 +13,12 @@ interface CalendarRepository { fun instances(range: ClosedRange): Flow> suspend fun eventDetail(eventId: Long): EventDetail + /** + * The event-colour palette a calendar's account publishes; empty when it + * exposes none (see [CalendarDataSource.eventColorPalette]). + */ + suspend fun eventColorPalette(calendarId: Long): List + /** Create a device-only (LOCAL) calendar the app owns; returns its id. */ suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 5e2b50b..df687a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs 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 @@ -70,6 +71,9 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) } + override suspend fun eventColorPalette(calendarId: Long): List = + withContext(io) { dataSource.eventColorPalette(calendarId) } + override suspend fun createLocalCalendar( displayName: String, color: Int, 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 d5372af..e307130 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 @@ -46,11 +46,16 @@ internal fun ColumnReader.toEventDetailCore( // localized placeholder, and the edit form must prefill the true value. val title = getString(EventDetailProjection.IDX_TITLE).orEmpty() - val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { - getInt(EventDetailProjection.IDX_CALENDAR_COLOR) + // The event's own colour (null = inherits the calendar's) is kept apart + // from the resolved display colour: the edit form needs to tell the two + // cases apart, while the instance carries the calendar fallback for display. + val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { + null } else { getInt(EventDetailProjection.IDX_EVENT_COLOR) } + val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY) + val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR) val eventId = getLong(EventDetailProjection.IDX_EVENT_ID) val instance = EventInstance( @@ -87,6 +92,8 @@ internal fun ColumnReader.toEventDetailCore( accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)), eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE), selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)), + eventColor = eventColor, + eventColorKey = eventColorKey, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 4293ed2..0f50930 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -85,6 +85,9 @@ internal fun buildEventUpdateValues( if (updated.accessLevel != original.accessLevel) { put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue()) } + if (updated.colorKey != original.colorKey || updated.color != original.color) { + putAll(eventColorColumns(updated.colorKey, updated.color)) + } val timesChanged = updated.start != original.start || updated.end != original.end || @@ -134,6 +137,28 @@ internal fun buildOccurrenceExceptionValues( put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null }) + putAll(eventColorColumns(form.colorKey, form.color)) +} + +/** + * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A + * [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the + * account's palette, so the colour round-trips through sync); a raw [color] + * writes `EVENT_COLOR` and clears any key; "no colour" clears both so the event + * falls back to its calendar's colour. The two are never written together — + * the provider rejects a raw colour on a calendar that publishes a palette, + * which is exactly why palette calendars only ever go through the key. + */ +internal fun eventColorColumns(colorKey: String?, color: Int?): Map = when { + colorKey != null -> mapOf(CalendarContract.Events.EVENT_COLOR_KEY to colorKey) + color != null -> mapOf( + CalendarContract.Events.EVENT_COLOR_KEY to null, + CalendarContract.Events.EVENT_COLOR to color, + ) + else -> mapOf( + CalendarContract.Events.EVENT_COLOR_KEY to null, + CalendarContract.Events.EVENT_COLOR to null, + ) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 6bb8318..d1e8403 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -74,6 +74,7 @@ internal object EventDetailProjection { CalendarContract.Events.ACCESS_LEVEL, CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.SELF_ATTENDEE_STATUS, + CalendarContract.Events.EVENT_COLOR_KEY, ) const val IDX_EVENT_ID = 0 @@ -93,6 +94,7 @@ internal object EventDetailProjection { const val IDX_ACCESS_LEVEL = 14 const val IDX_EVENT_TIMEZONE = 15 const val IDX_SELF_ATTENDEE_STATUS = 16 + const val IDX_EVENT_COLOR_KEY = 17 } internal object AttendeeProjection { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index a96e778..985f8b6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -99,6 +99,22 @@ class SettingsPrefs @Inject constructor( store.edit { it[REMINDERS_ENABLED_KEY] = enabled } } + /** + * Whether to offer a custom event colour even on calendars that publish no + * colour palette (most local calendars handle it fine; synced calendars + * without a palette — some CalDAV — may drop or overwrite a raw colour on + * their next sync). Defaults to OFF: such calendars hide the colour picker + * until the user opts in, accepting the limitation. Local calendars and + * palette-backed calendars (Google, …) are unaffected by this flag. + */ + val allowColorOnUnsupportedCalendars: Flow = store.data.map { prefs -> + prefs[ALLOW_COLOR_UNSUPPORTED_KEY] ?: false + } + + suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) { + store.edit { it[ALLOW_COLOR_UNSUPPORTED_KEY] = enabled } + } + /** * Whether the one-time reminder onboarding step (after the calendar * grant) has been shown — also true for users who tapped "not now". @@ -125,6 +141,8 @@ class SettingsPrefs @Inject constructor( internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") + internal val ALLOW_COLOR_UNSUPPORTED_KEY = + booleanPreferencesKey("allow_color_unsupported_calendars") internal val DEFAULT_FORM_FIELDS = setOf(EventFormField.Location, EventFormField.Description) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index e892c5f..3cfdc40 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -30,6 +30,17 @@ data class EventForm( * those are kept verbatim until the user picks something else. */ val rrule: String? = null, + /** + * The event's own colour, or null to inherit the calendar's colour. + * [colorKey] is a `Colors.COLOR_KEY` from the calendar account's published + * event palette (Google, some CalDAV) — written as `EVENT_COLOR_KEY` so it + * round-trips through sync. When it is null but [color] is set, [color] is + * a raw ARGB written as `EVENT_COLOR` (local calendars, or synced ones the + * user opted into despite no palette). [color] mirrors the key's swatch when + * [colorKey] is set, so the picker can highlight it. + */ + val colorKey: String? = null, + val color: Int? = null, ) /** @@ -43,6 +54,7 @@ enum class EventFormField { Recurrence, Availability, Visibility, + Color, } enum class EventFormProblem { @@ -91,6 +103,11 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): availability = availability, accessLevel = accessLevel, rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, + // The provider fills EVENT_COLOR from the key, so [color] is the + // swatch either way; a null colour means the event inherits its + // calendar's colour. + colorKey = eventColorKey, + color = eventColor, ) } @@ -130,6 +147,7 @@ fun EventForm.populatedFields(): Set = buildSet { if (rrule != null) add(EventFormField.Recurrence) if (availability != Availability.Busy) add(EventFormField.Availability) if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) + if (colorKey != null || color != null) add(EventFormField.Color) } fun EventForm.problems(): Set = buildSet { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 1e08f58..b487ec9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -58,8 +58,25 @@ data class EventDetail( val eventTimezone: String? = null, /** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */ val selfStatus: AttendeeStatus = AttendeeStatus.Unknown, + /** + * The event's own raw colour (`Events.EVENT_COLOR`), null when the event + * inherits its calendar's colour. Unlike [EventInstance.color] (which + * already folds in the calendar fallback for display) this stays null so + * the edit form can tell "has own colour" from "inherits". + */ + val eventColor: Int? = null, + /** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */ + val eventColorKey: String? = null, ) +/** + * One selectable event colour published by a calendar's account + * (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped + * `COLOR_KEY` written as `EVENT_COLOR_KEY` (so the colour survives sync), + * [argb] is the swatch it renders as. + */ +data class EventColorOption(val key: String, val argb: Int) + data class Attendee( val name: String, val email: String?, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 2c49d50..5a6de7b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -6,14 +6,10 @@ import android.content.Intent import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -31,7 +27,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit @@ -73,8 +68,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold +import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.Position @@ -325,7 +322,12 @@ private fun CalendarEditor( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(12.dp)) - ColorPalette(selected = color, onSelect = { color = it }, dark = dark) + ColorSwatchRow( + colors = CALENDAR_COLOR_PALETTE, + selected = color, + onSelect = { color = it }, + dark = dark, + ) } EditorCard( icon = Icons.AutoMirrored.Filled.Notes, @@ -402,42 +404,6 @@ private fun EditorCard( } } -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun ColorPalette(selected: Int, onSelect: (Int) -> Unit, dark: Boolean) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - CALENDAR_COLOR_PALETTE.forEach { argb -> - val isSelected = argb == selected - // Show the pastel the calendar will actually render as, not the raw hue. - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .padding(vertical = 4.dp) - .size(40.dp) - .clip(CircleShape) - .background(pastelize(argb, dark)) - .then( - if (isSelected) { - Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) - } else { - Modifier - }, - ) - .clickable { onSelect(argb) }, - ) { - if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = null, - tint = Color.Black.copy(alpha = 0.7f), - modifier = Modifier.size(20.dp), - ) - } - } - } - } -} - @Composable private fun AccountHeader(account: String, accountType: String) { val context = LocalContext.current @@ -532,15 +498,3 @@ private fun curatedSourcePackage(accountType: String): String? = when { else -> null } -/** Google-Calendar-style palette; values are ARGB ints for `Calendars.CALENDAR_COLOR`. */ -internal val CALENDAR_COLOR_PALETTE: List = listOf( - 0xFFD50000, // red - 0xFFE67C00, // orange - 0xFFF6BF26, // amber - 0xFF33B679, // green - 0xFF0B8043, // dark green - 0xFF039BE5, // blue - 0xFF3F51B5, // indigo - 0xFF8E24AA, // purple - 0xFF616161, // graphite -).map { it.toInt() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt new file mode 100644 index 0000000..6fd11ec --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt @@ -0,0 +1,82 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A wrapping row of round colour swatches; the one matching [selected] is + * ringed and checked. Shared by the calendar editor and the event-colour + * picker so both pick a colour the same way. Swatches render through + * [pastelize] — the softened colour the app actually paints, not the raw hue. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ColorSwatchRow( + colors: List, + selected: Int?, + onSelect: (Int) -> Unit, + dark: Boolean, + modifier: Modifier = Modifier, +) { + FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + colors.forEach { argb -> + val isSelected = argb == selected + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(vertical = 4.dp) + .size(40.dp) + .clip(CircleShape) + .background(pastelize(argb, dark)) + .then( + if (isSelected) { + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + } else { + Modifier + }, + ) + .clickable { onSelect(argb) }, + ) { + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.Black.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp), + ) + } + } + } + } +} + +/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */ +val CALENDAR_COLOR_PALETTE: List = listOf( + 0xFFD50000, // red + 0xFFE67C00, // orange + 0xFFF6BF26, // amber + 0xFF33B679, // green + 0xFF0B8043, // dark green + 0xFF039BE5, // blue + 0xFF3F51B5, // indigo + 0xFF8E24AA, // purple + 0xFF616161, // graphite +).map { it.toInt() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 7d28404..2424e59 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat @@ -102,6 +103,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd @@ -110,6 +112,8 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.toRRule +import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE +import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale @@ -414,6 +418,7 @@ private fun EventEditContent( var showReminderPicker by rememberSaveable { mutableStateOf(false) } var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } + var showColorPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } @@ -423,6 +428,16 @@ private fun EventEditContent( ?: MaterialTheme.colorScheme.primary val gap = 12.dp + // Per-event colour applicability for the resolved calendar: + // - palette calendars (Google, …) and local calendars always support it; + // - synced calendars with no palette only when the user opted in, and even + // then the colour may not survive the calendar's next sync (the warning). + val isLocalCalendar = selectedCalendar?.isLocal == true + val colorSupported = state.colorPalette.isNotEmpty() || isLocalCalendar || + state.allowColorOnUnsupportedCalendars + val colorSyncRisk = state.colorPalette.isEmpty() && !isLocalCalendar && + state.allowColorOnUnsupportedCalendars + Column( modifier = modifier // Shrink the scroll viewport by the keyboard instead of letting @@ -692,6 +707,67 @@ private fun EventEditContent( } } + OptionalFormSection(visible = EventFormField.Color in state.visibleFields) { + Spacer(Modifier.height(gap)) + // The swatch the event will paint with: its own colour, else the + // calendar's. The Palette icon takes that colour as a preview. + val swatch = form.color ?: selectedCalendar?.color + EditCard( + icon = Icons.Default.Palette, + iconContentDescription = stringResource(R.string.event_edit_color), + iconTint = if (colorSupported && swatch != null) { + pastelize(swatch, dark) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + onClick = { showColorPicker = true }.takeIf { colorSupported }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + when { + !colorSupported -> R.string.event_edit_color_unsupported + form.color != null -> R.string.event_edit_color_custom + else -> R.string.event_edit_color_default + }, + ), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource( + if (colorSupported) { + R.string.event_edit_color + } else { + R.string.event_edit_color_unsupported_hint + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (colorSyncRisk) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.event_edit_color_sync_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (colorSupported) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( @@ -779,6 +855,28 @@ private fun EventEditContent( ) } + if (showColorPicker) { + ColorPickerDialog( + palette = state.colorPalette, + selected = form.color, + hasExplicitColor = form.color != null, + syncWarning = colorSyncRisk, + onPickKey = { key, argb -> + viewModel.setColorKey(key, argb) + showColorPicker = false + }, + onPickRaw = { argb -> + viewModel.setColorRaw(argb) + showColorPicker = false + }, + onClear = { + viewModel.clearColor() + showColorPicker = false + }, + onDismiss = { showColorPicker = false }, + ) + } + if (showFieldPicker) { FieldPickerDialog( hiddenFields = state.hiddenFields, @@ -1294,6 +1392,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility + EventFormField.Color -> R.string.event_edit_color } private fun fieldIcon(field: EventFormField): ImageVector = when (field) { @@ -1303,6 +1402,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Visibility -> Icons.Default.Lock + EventFormField.Color -> Icons.Default.Palette } /** @@ -1336,6 +1436,72 @@ private fun VisibilityPickerDialog( ) } +/** + * Event-colour picker: just the swatches. A non-empty [palette] (the calendar + * account's published colours) picks by key so the colour round-trips through + * sync; otherwise the app's own palette writes a raw colour, with a + * [syncWarning] when that calendar may not keep it. The "Reset" button (shown + * only once a colour is set) drops back to the calendar's own colour. + */ +@Composable +private fun ColorPickerDialog( + palette: List, + selected: Int?, + hasExplicitColor: Boolean, + syncWarning: Boolean, + onPickKey: (String, Int) -> Unit, + onPickRaw: (Int) -> Unit, + onClear: () -> Unit, + onDismiss: () -> Unit, +) { + val dark = isSystemInDarkTheme() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.event_edit_color)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (palette.isNotEmpty()) { + ColorSwatchRow( + colors = palette.map { it.argb }, + selected = selected, + onSelect = { argb -> + palette.firstOrNull { it.argb == argb } + ?.let { onPickKey(it.key, it.argb) } + }, + dark = dark, + ) + } else { + ColorSwatchRow( + colors = CALENDAR_COLOR_PALETTE, + selected = selected, + onSelect = onPickRaw, + dark = dark, + ) + if (syncWarning) { + Text( + text = stringResource(R.string.event_edit_color_sync_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + dismissButton = if (hasExplicitColor) { + { + TextButton(onClick = onClear) { + Text(stringResource(R.string.event_edit_color_reset)) + } + } + } else { + null + }, + ) +} + private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) { AccessLevel.Default -> Icons.Default.Tune AccessLevel.Public -> Icons.Default.Public diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index dd13a49..b9186dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.edit import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormProblem @@ -33,6 +34,18 @@ data class EventEditUiState( * then drops "only this event" (an exception row can't carry a rule). */ val recurrenceChanged: Boolean = false, + /** + * The event-colour palette the resolved target calendar publishes; empty + * when it exposes none. Non-empty → the colour picker offers these swatches + * (written as a key, sync-safe); empty → see [colorMode]. + */ + val colorPalette: List = emptyList(), + /** + * Whether the user has opted into custom colours on calendars that publish + * no palette (a synced one may then drop the colour on sync). Mirrors the + * settings flag; ignored for local and palette-backed calendars. + */ + val allowColorOnUnsupportedCalendars: Boolean = false, ) /** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 15f0d38..bb50ebf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EditSnapshot +import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.RecurringWriteScope @@ -19,12 +20,17 @@ import de.jeanlucmakiola.calendula.domain.populatedFields import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.toEditSnapshot import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -98,19 +104,44 @@ class EventEditViewModel @Inject constructor( val writable: List, val lastUsed: Long?, val defaultFields: Set, + val allowColorOnUnsupported: Boolean, ) + /** Writable calendars — the only valid event targets. */ + private val writableCalendars: Flow> = repository.calendars() + .map { calendars -> calendars.filter { it.canModifyContents } } + .catch { emit(emptyList()) } + + /** The target calendar id, resolved exactly as the form shows it. */ + private val resolvedCalendarId: Flow = combine( + _form.map { it?.calendarId }, + writableCalendars, + prefs.lastUsedCalendarId, + ) { picked, writable, lastUsed -> + picked + ?: lastUsed?.takeIf { id -> writable.any { it.id == id } } + ?: writable.firstOrNull()?.id + }.distinctUntilChanged() + + /** The resolved calendar's published event palette, refetched when it changes. */ + @OptIn(ExperimentalCoroutinesApi::class) + private val colorPalette: Flow> = resolvedCalendarId + .flatMapLatest { id -> + flow { emit(id?.let { repository.eventColorPalette(it) }.orEmpty()) } + } + .flowOn(io) + val state: StateFlow = combine( combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs), combine( - repository.calendars() - .map { calendars -> calendars.filter { it.canModifyContents } } - .catch { emit(emptyList()) }, + writableCalendars, prefs.lastUsedCalendarId, settingsPrefs.defaultFormFields, + settingsPrefs.allowColorOnUnsupportedCalendars, ::ExternalInputs, ).flowOn(io), - ) { local, external -> + colorPalette, + ) { local, external, palette -> val form = local.form ?: return@combine null val resolvedId = form.calendarId ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } @@ -129,6 +160,8 @@ class EventEditViewModel @Inject constructor( // the scope dialog drops "only this event" after a rule change. recurrenceChanged = local.editTarget != null && resolved.rrule != local.editTarget.original.rrule, + colorPalette = palette, + allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported, ) } .stateIn( @@ -207,10 +240,25 @@ class EventEditViewModel @Inject constructor( fun setLocation(value: String) = update { it.copy(location = value) } fun setDescription(value: String) = update { it.copy(description = value) } fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) } - fun setCalendar(id: Long) = update { it.copy(calendarId = id) } + + /** + * Switching calendars drops any chosen colour: a palette key is + * account-scoped, and a raw colour may be invalid on the new calendar. + * The event falls back to the new calendar's colour until re-picked. + */ + fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) } fun setAvailability(value: Availability) = update { it.copy(availability = value) } fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) } + /** Pick a palette colour (round-trips via its key); [argb] is its swatch. */ + fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) } + + /** Pick a raw colour (local / opted-in calendars), clearing any palette key. */ + fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) } + + /** Clear the colour so the event inherits its calendar's. */ + fun clearColor() = update { it.copy(colorKey = null, color = null) } + /** Bare RRULE value from the recurrence picker; null = does not repeat. */ fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 3483664..f0949b5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -431,6 +431,27 @@ private fun EventFormScreen( onClick = { viewModel.setFormFieldDefault(field, !checked) }, ) } + + // Per-event colour on calendars that publish no colour set (some + // CalDAV) — off by default, with the honest caveat that the colour may + // not survive their next sync. Local and palette calendars ignore it. + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_color_unsupported), + summary = stringResource(R.string.settings_color_unsupported_hint), + position = Position.Alone, + trailing = { + Switch( + checked = state.allowColorOnUnsupportedCalendars, + onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) }, + ) + }, + onClick = { + viewModel.setAllowColorOnUnsupportedCalendars( + !state.allowColorOnUnsupportedCalendars, + ) + }, + ) } } @@ -555,6 +576,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility + EventFormField.Color -> R.string.event_edit_color } @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 2047215..813f9b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -20,4 +20,9 @@ data class SettingsUiState( val defaultFormFields: Set = SettingsPrefs.DEFAULT_FORM_FIELDS, /** Whether Calendula posts reminder notifications (v1.4). */ val remindersEnabled: Boolean = true, + /** + * Whether the event-colour picker is offered on calendars that publish no + * colour palette (the colour may then not survive their next sync). + */ + val allowColorOnUnsupportedCalendars: Boolean = false, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 0d6a730..559cb99 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -24,20 +24,27 @@ class SettingsViewModel @Inject constructor( val state: StateFlow = combine( - prefs.themeMode, - prefs.dynamicColor, - prefs.weekStart, - prefs.defaultFormFields, - prefs.remindersEnabled, - ) { theme, dynamic, weekStart, formFields, reminders -> - SettingsUiState( - themeMode = theme, - dynamicColor = dynamic && dynamicColorAvailable, - dynamicColorAvailable = dynamicColorAvailable, - weekStart = weekStart, - defaultFormFields = formFields, - remindersEnabled = reminders, - ) + // combine() only types up to five flows, so the sixth pref folds + // into the assembled state in an outer combine. + combine( + prefs.themeMode, + prefs.dynamicColor, + prefs.weekStart, + prefs.defaultFormFields, + prefs.remindersEnabled, + ) { theme, dynamic, weekStart, formFields, reminders -> + SettingsUiState( + themeMode = theme, + dynamicColor = dynamic && dynamicColorAvailable, + dynamicColorAvailable = dynamicColorAvailable, + weekStart = weekStart, + defaultFormFields = formFields, + remindersEnabled = reminders, + ) + }, + prefs.allowColorOnUnsupportedCalendars, + ) { base, allowColor -> + base.copy(allowColorOnUnsupportedCalendars = allowColor) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), @@ -63,4 +70,8 @@ class SettingsViewModel @Inject constructor( fun setRemindersEnabled(enabled: Boolean) { viewModelScope.launch { prefs.setRemindersEnabled(enabled) } } + + fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) { + viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) } + } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b31a500..d2d652f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -82,6 +82,15 @@ Verfügbarkeit Sichtbarkeit + + Farbe + Kalenderfarbe + Eigene Farbe + Zurücksetzen + Für diesen Kalender nicht verfügbar + Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren. + Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung. + Termin wurde extern geändert Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren? @@ -208,6 +217,8 @@ Sonntag Termin-Formular Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\" + Farben auf nicht unterstützten Kalendern erlauben + Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden. Benachrichtigungen Termin-Erinnerungen Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7319365..3043346 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,15 @@ Availability Visibility + + Color + Calendar color + Custom color + Reset + Not available for this calendar + This calendar publishes no color set. You can allow custom colors for such calendars in Settings. + This calendar may drop or overwrite the color on its next sync. + Event changed elsewhere While you were editing, this event was changed — by sync or another app. What should happen to your changes? @@ -209,6 +218,8 @@ Sunday New event form Fields shown by default — everything else sits behind \"More fields\" + Allow colors on unsupported calendars + Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix. Notifications Event reminders Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two. 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 e7708ca..58225fe 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 @@ -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() + } } 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 ab96723..6ef9528 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 @@ -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 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 5fd2052..40aac33 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 @@ -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) + } } 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 05a0fee..19faac4 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 @@ -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 = emptyList() var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } + var eventColorPaletteResult: (Long) -> List = { 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 = instancesResult(beginMillis, endMillis) override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) + override fun eventColorPalette(calendarId: Long): List = + eventColorPaletteResult(calendarId) override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long { writeError?.let { throw it } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index bc735b5..c6a9066 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -115,6 +115,8 @@ class EventFormTest { rowStart: Long = 0L, rowEnd: Long = 0L, attendees: List = 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) + } }