Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b62f097392 |
@@ -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
|
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
|
||||||
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
||||||
above)*
|
above)*
|
||||||
4. **Per-event color** *(next)* — reuses the calendar color picker/palette; closes the create/edit theme
|
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
||||||
5. Duplicate event — detail action → prefilled create form; near-free on the tap-to-create prefill infra
|
`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.)
|
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-16*
|
*Last updated: 2026-06-17*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -105,13 +105,24 @@ backlog is now organised by theme in `ROADMAP.md`.
|
|||||||
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
|
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
|
||||||
- Donate button on the About card deferred (target still TBD).
|
- 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
|
## 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"
|
2. Decide the "Locations & People" and "remote calendar create/edit"
|
||||||
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
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
|
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
|
||||||
from local calendar management; finishes the create/edit theme.
|
agenda view (strategic, backs a future widget). Full ranked sequence in
|
||||||
4. Then agenda view (strategic, backs a future widget); jump-to-date and
|
|
||||||
duplicate event remain cheap follow-ups. Full ranked sequence in
|
|
||||||
`ROADMAP.md` → "Near-term sequence".
|
`ROADMAP.md` → "Near-term sequence".
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [2.3.0] — 2026-06-16
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
// (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.
|
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||||
versionCode = 20300
|
versionCode = 20400
|
||||||
versionName = "2.3.0"
|
versionName = "2.4.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import android.util.Log
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -37,6 +38,15 @@ interface CalendarDataSource {
|
|||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
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<EventColorOption>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
* 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<EventColorOption> {
|
||||||
|
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 {
|
override fun insertEvent(form: EventForm): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -240,6 +290,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
form.description.trim().takeIf { it.isNotEmpty() }
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
?.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)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -12,6 +13,12 @@ interface CalendarRepository {
|
|||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
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<EventColorOption>
|
||||||
|
|
||||||
/** Create a device-only (LOCAL) calendar the app owns; returns its id. */
|
/** Create a device-only (LOCAL) calendar the app owns; returns its id. */
|
||||||
suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
|
suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -70,6 +71,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||||
|
|
||||||
override suspend fun createLocalCalendar(
|
override suspend fun createLocalCalendar(
|
||||||
displayName: String,
|
displayName: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
|
|||||||
@@ -46,11 +46,16 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
// localized placeholder, and the edit form must prefill the true value.
|
// localized placeholder, and the edit form must prefill the true value.
|
||||||
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
||||||
|
|
||||||
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
// The event's own colour (null = inherits the calendar's) is kept apart
|
||||||
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
// 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 {
|
} else {
|
||||||
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
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 eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
val instance = EventInstance(
|
val instance = EventInstance(
|
||||||
@@ -87,6 +92,8 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
||||||
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
||||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
|
eventColor = eventColor,
|
||||||
|
eventColorKey = eventColorKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ internal fun buildEventUpdateValues(
|
|||||||
if (updated.accessLevel != original.accessLevel) {
|
if (updated.accessLevel != original.accessLevel) {
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
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 ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
@@ -134,6 +137,28 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||||
put(CalendarContract.Events.DESCRIPTION, form.description.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<String, Any?> = 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.ACCESS_LEVEL,
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -93,6 +94,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_ACCESS_LEVEL = 14
|
const val IDX_ACCESS_LEVEL = 14
|
||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
|
|||||||
@@ -99,6 +99,22 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
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<Boolean> = 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
|
* Whether the one-time reminder onboarding step (after the calendar
|
||||||
* grant) has been shown — also true for users who tapped "not now".
|
* 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 FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
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 =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ data class EventForm(
|
|||||||
* those are kept verbatim until the user picks something else.
|
* those are kept verbatim until the user picks something else.
|
||||||
*/
|
*/
|
||||||
val rrule: String? = null,
|
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,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
Visibility,
|
Visibility,
|
||||||
|
Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class EventFormProblem {
|
enum class EventFormProblem {
|
||||||
@@ -91,6 +103,11 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
availability = availability,
|
availability = availability,
|
||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
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<EventFormField> = buildSet {
|
|||||||
if (rrule != null) add(EventFormField.Recurrence)
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
|
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
|||||||
@@ -58,8 +58,25 @@ data class EventDetail(
|
|||||||
val eventTimezone: String? = null,
|
val eventTimezone: String? = null,
|
||||||
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
||||||
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
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(
|
data class Attendee(
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String?,
|
val email: String?,
|
||||||
|
|||||||
@@ -6,14 +6,10 @@ import android.content.Intent
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
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.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
@@ -73,8 +68,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
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.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
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.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
@@ -325,7 +322,12 @@ private fun CalendarEditor(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
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(
|
EditorCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
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
|
@Composable
|
||||||
private fun AccountHeader(account: String, accountType: String) {
|
private fun AccountHeader(account: String, accountType: String) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -532,15 +498,3 @@ private fun curatedSourcePackage(accountType: String): String? = when {
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google-Calendar-style palette; values are ARGB ints for `Calendars.CALENDAR_COLOR`. */
|
|
||||||
internal val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
|
||||||
0xFFD50000, // red
|
|
||||||
0xFFE67C00, // orange
|
|
||||||
0xFFF6BF26, // amber
|
|
||||||
0xFF33B679, // green
|
|
||||||
0xFF0B8043, // dark green
|
|
||||||
0xFF039BE5, // blue
|
|
||||||
0xFF3F51B5, // indigo
|
|
||||||
0xFF8E24AA, // purple
|
|
||||||
0xFF616161, // graphite
|
|
||||||
).map { it.toInt() }
|
|
||||||
|
|||||||
@@ -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<Int>,
|
||||||
|
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<Int> = listOf(
|
||||||
|
0xFFD50000, // red
|
||||||
|
0xFFE67C00, // orange
|
||||||
|
0xFFF6BF26, // amber
|
||||||
|
0xFF33B679, // green
|
||||||
|
0xFF0B8043, // dark green
|
||||||
|
0xFF039BE5, // blue
|
||||||
|
0xFF3F51B5, // indigo
|
||||||
|
0xFF8E24AA, // purple
|
||||||
|
0xFF616161, // graphite
|
||||||
|
).map { it.toInt() }
|
||||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.EventAvailable
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
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.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
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.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
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.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
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.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
@@ -414,6 +418,7 @@ private fun EventEditContent(
|
|||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
@@ -423,6 +428,16 @@ private fun EventEditContent(
|
|||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
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(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
// Shrink the scroll viewport by the keyboard instead of letting
|
// 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()) {
|
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextButton(
|
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) {
|
if (showFieldPicker) {
|
||||||
FieldPickerDialog(
|
FieldPickerDialog(
|
||||||
hiddenFields = state.hiddenFields,
|
hiddenFields = state.hiddenFields,
|
||||||
@@ -1294,6 +1392,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
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.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
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<EventColorOption>,
|
||||||
|
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) {
|
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
||||||
AccessLevel.Default -> Icons.Default.Tune
|
AccessLevel.Default -> Icons.Default.Tune
|
||||||
AccessLevel.Public -> Icons.Default.Public
|
AccessLevel.Public -> Icons.Default.Public
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
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).
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
*/
|
*/
|
||||||
val recurrenceChanged: Boolean = false,
|
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<EventColorOption> = 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. */
|
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
|
|||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
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.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
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.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -98,19 +104,44 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
val defaultFields: Set<EventFormField>,
|
val defaultFields: Set<EventFormField>,
|
||||||
|
val allowColorOnUnsupported: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Writable calendars — the only valid event targets. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = 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<Long?> = 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<List<EventColorOption>> = resolvedCalendarId
|
||||||
|
.flatMapLatest { id ->
|
||||||
|
flow { emit(id?.let { repository.eventColorPalette(it) }.orEmpty()) }
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
|
||||||
val state: StateFlow<EventEditUiState?> = combine(
|
val state: StateFlow<EventEditUiState?> = combine(
|
||||||
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
||||||
combine(
|
combine(
|
||||||
repository.calendars()
|
writableCalendars,
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
|
||||||
.catch { emit(emptyList()) },
|
|
||||||
prefs.lastUsedCalendarId,
|
prefs.lastUsedCalendarId,
|
||||||
settingsPrefs.defaultFormFields,
|
settingsPrefs.defaultFormFields,
|
||||||
|
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
) { local, external ->
|
colorPalette,
|
||||||
|
) { local, external, palette ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: 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.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
recurrenceChanged = local.editTarget != null &&
|
recurrenceChanged = local.editTarget != null &&
|
||||||
resolved.rrule != local.editTarget.original.rrule,
|
resolved.rrule != local.editTarget.original.rrule,
|
||||||
|
colorPalette = palette,
|
||||||
|
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -207,10 +240,25 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = 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 setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = 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. */
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
|
|||||||
@@ -431,6 +431,27 @@ private fun EventFormScreen(
|
|||||||
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
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.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -20,4 +20,9 @@ data class SettingsUiState(
|
|||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
|
combine(
|
||||||
|
// combine() only types up to five flows, so the sixth pref folds
|
||||||
|
// into the assembled state in an outer combine.
|
||||||
combine(
|
combine(
|
||||||
prefs.themeMode,
|
prefs.themeMode,
|
||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
@@ -38,6 +41,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
defaultFormFields = formFields,
|
defaultFormFields = formFields,
|
||||||
remindersEnabled = reminders,
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
prefs.allowColorOnUnsupportedCalendars,
|
||||||
|
) { base, allowColor ->
|
||||||
|
base.copy(allowColorOnUnsupportedCalendars = allowColor)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
@@ -63,4 +70,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setRemindersEnabled(enabled: Boolean) {
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,15 @@
|
|||||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||||
|
|
||||||
|
<!-- Termin-Formular — eigene Terminfarbe -->
|
||||||
|
<string name="event_edit_color">Farbe</string>
|
||||||
|
<string name="event_edit_color_default">Kalenderfarbe</string>
|
||||||
|
<string name="event_edit_color_custom">Eigene Farbe</string>
|
||||||
|
<string name="event_edit_color_reset">Zurücksetzen</string>
|
||||||
|
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
||||||
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
||||||
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
||||||
@@ -208,6 +217,8 @@
|
|||||||
<string name="settings_week_start_sunday">Sonntag</string>
|
<string name="settings_week_start_sunday">Sonntag</string>
|
||||||
<string name="settings_section_event_form">Termin-Formular</string>
|
<string name="settings_section_event_form">Termin-Formular</string>
|
||||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
|
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||||
|
<string name="settings_color_unsupported_hint">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.</string>
|
||||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
<string name="settings_reminders">Termin-Erinnerungen</string>
|
<string name="settings_reminders">Termin-Erinnerungen</string>
|
||||||
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||||
|
|||||||
@@ -83,6 +83,15 @@
|
|||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</string>
|
<string name="event_edit_visibility">Visibility</string>
|
||||||
|
|
||||||
|
<!-- Event form — per-event color -->
|
||||||
|
<string name="event_edit_color">Color</string>
|
||||||
|
<string name="event_edit_color_default">Calendar color</string>
|
||||||
|
<string name="event_edit_color_custom">Custom color</string>
|
||||||
|
<string name="event_edit_color_reset">Reset</string>
|
||||||
|
<string name="event_edit_color_unsupported">Not available for this calendar</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">This calendar publishes no color set. You can allow custom colors for such calendars in Settings.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">This calendar may drop or overwrite the color on its next sync.</string>
|
||||||
|
|
||||||
<!-- Event form — save conflict (v2.0) -->
|
<!-- Event form — save conflict (v2.0) -->
|
||||||
<string name="event_edit_conflict_title">Event changed elsewhere</string>
|
<string name="event_edit_conflict_title">Event changed elsewhere</string>
|
||||||
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
|
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
|
||||||
@@ -209,6 +218,8 @@
|
|||||||
<string name="settings_week_start_sunday">Sunday</string>
|
<string name="settings_week_start_sunday">Sunday</string>
|
||||||
<string name="settings_section_event_form">New event form</string>
|
<string name="settings_section_event_form">New event form</string>
|
||||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
|
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||||
|
<string name="settings_color_unsupported_hint">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.</string>
|
||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
<string name="settings_reminders">Event reminders</string>
|
<string name="settings_reminders">Event reminders</string>
|
||||||
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import app.cash.turbine.test
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -400,4 +401,20 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(expected.message).contains("999")
|
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",
|
organizer: String? = "x@y",
|
||||||
rrule: String? = null,
|
rrule: String? = null,
|
||||||
eventColor: Any? = null,
|
eventColor: Any? = null,
|
||||||
|
eventColorKey: String? = null,
|
||||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
dtstart: Long = 1_000_000_000L,
|
dtstart: Long = 1_000_000_000L,
|
||||||
dtend: Long = 1_000_003_600L,
|
dtend: Long = 1_000_003_600L,
|
||||||
@@ -49,6 +50,7 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
|
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(
|
private fun attendeeReader(
|
||||||
@@ -99,6 +101,22 @@ class EventDetailMapperTest {
|
|||||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||||
.toDetail()
|
.toDetail()
|
||||||
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
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
|
@Test
|
||||||
|
|||||||
@@ -218,4 +218,83 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, 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
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -14,6 +15,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var calendarsResult: List<CalendarSource> = emptyList()
|
var calendarsResult: List<CalendarSource> = emptyList()
|
||||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
|
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
/** Set to make the next write call throw. */
|
/** Set to make the next write call throw. */
|
||||||
var writeError: Exception? = null
|
var writeError: Exception? = null
|
||||||
/** Id returned by the next [insertEvent]. */
|
/** Id returned by the next [insertEvent]. */
|
||||||
@@ -45,6 +47,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
instancesResult(beginMillis, endMillis)
|
instancesResult(beginMillis, endMillis)
|
||||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
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 {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ class EventFormTest {
|
|||||||
rowStart: Long = 0L,
|
rowStart: Long = 0L,
|
||||||
rowEnd: Long = 0L,
|
rowEnd: Long = 0L,
|
||||||
attendees: List<Attendee> = emptyList(),
|
attendees: List<Attendee> = emptyList(),
|
||||||
|
eventColor: Int? = null,
|
||||||
|
eventColorKey: String? = null,
|
||||||
): EventDetail = EventDetail(
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 1L,
|
instanceId = 1L,
|
||||||
@@ -134,6 +136,8 @@ class EventFormTest {
|
|||||||
reminders = reminders,
|
reminders = reminders,
|
||||||
availability = availability,
|
availability = availability,
|
||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
|
eventColor = eventColor,
|
||||||
|
eventColorKey = eventColorKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -227,6 +231,7 @@ class EventFormTest {
|
|||||||
rrule = "FREQ=DAILY",
|
rrule = "FREQ=DAILY",
|
||||||
availability = Availability.Free,
|
availability = Availability.Free,
|
||||||
accessLevel = AccessLevel.Private,
|
accessLevel = AccessLevel.Private,
|
||||||
|
color = 0xFFD50000.toInt(),
|
||||||
)
|
)
|
||||||
assertThat(full.populatedFields()).containsExactly(
|
assertThat(full.populatedFields()).containsExactly(
|
||||||
EventFormField.Location,
|
EventFormField.Location,
|
||||||
@@ -235,6 +240,33 @@ class EventFormTest {
|
|||||||
EventFormField.Recurrence,
|
EventFormField.Recurrence,
|
||||||
EventFormField.Availability,
|
EventFormField.Availability,
|
||||||
EventFormField.Visibility,
|
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