feat(colors): optional raw calendar colours + readable title contrast

Add a "Soften calendar colours" setting (Settings → Design, default on).
Turning it off paints calendar and event colours raw, exactly as the sync
source (DAVx5/CalDAV) publishes them, instead of the theme-fitting pastels
(#36).

Event/calendar colours now flow through shared eventFill()/eventInk()
helpers gated by a LocalSoftenColors composition local (widgets read the
pref directly). Event titles pick black or white text by the fill's WCAG
relative luminance, so a dark colour stays legible whether softened or raw
(#21) — previously always near-black.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:26:34 +02:00
parent 2279738371
commit 50510a23da
21 changed files with 222 additions and 60 deletions

View File

@@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Show raw calendar colours. Calendula normally softens each calendar and event
colour toward a theme-fitting pastel so harsh sync colours read well on both
light and dark; a new **Soften calendar colours** setting (Settings → Design,
on by default) lets you turn that off and paint the exact colours your calendar
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
@leonp5 for the report ([#36]).
- Readable titles on dark event colours. An event bar's title now shows in white
on a dark colour and near-black on a light one, chosen automatically from the
colour's brightness, so a deep blue or purple event is legible at a glance in
the busy Week and Month views instead of dark-on-dark. This applies whether or
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
- A custom snooze duration. The **Snooze duration** setting (Settings → - A custom snooze duration. The **Snooze duration** setting (Settings →
Notifications) gains a **Custom…** option next to the minute presets: pick any Notifications) gains a **Custom…** option next to the minute presets: pick any
amount and switch between minutes and hours, so a snoozed reminder comes back amount and switch between minutes and hours, so a snoozed reminder comes back
@@ -971,10 +982,12 @@ automatically, with zero telemetry and no internet permission.
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25 [#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27 [#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32 [#32]: https://codeberg.org/jlmakiola/calendula/issues/32
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39 [#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35 [#35]: https://codeberg.org/jlmakiola/calendula/issues/35

View File

@@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -140,6 +141,7 @@ class MainActivity : AppCompatActivity() {
CompositionLocalProvider( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines, LocalShowHourLines provides settings.showHourLines,
LocalSoftenColors provides settings.softenColors,
) { ) {
RootScreen( RootScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View File

@@ -114,6 +114,21 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DYNAMIC_COLOR_KEY] = enabled } store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
} }
/**
* Whether raw provider colours are softened toward theme-fitting pastels
* before display (issue #36). Defaults to ON — the historical look, which
* caps harsh sync colours and pins brightness so entries read on both
* themes. Turning it off paints the calendar/event colour exactly as the
* sync source (DAVx5/CalDAV) publishes it.
*/
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
prefs[SOFTEN_COLORS_KEY] ?: true
}
suspend fun setSoftenCalendarColors(enabled: Boolean) {
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
}
/** /**
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque * Custom-font tokens per Material typeface role (issue #19). Stored as opaque
* strings — "system", "custom", or a bundled font's token — resolved to a * strings — "system", "custom", or a bundled font's token — resolved to a
@@ -679,6 +694,7 @@ class SettingsPrefs @Inject constructor(
companion object { companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font") internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font") internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp") internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")

View File

@@ -59,6 +59,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
@@ -72,7 +74,6 @@ import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -454,6 +455,7 @@ private fun AgendaEventRow(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow( GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
@@ -466,7 +468,7 @@ private fun AgendaEventRow(
modifier = Modifier modifier = Modifier
.size(width = 6.dp, height = 36.dp) .size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)), .background(eventFill(event.color, dark, soften)),
) )
}, },
onClick = onClick, onClick = onClick,

View File

@@ -93,6 +93,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
@@ -108,7 +110,6 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import java.time.LocalDate import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */ /** Sentinel [editorId] meaning "the editor is composing a new calendar". */
@@ -565,6 +566,7 @@ private fun CalendarEditor(
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) } var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
@@ -624,7 +626,7 @@ private fun CalendarEditor(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) { EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
InlineTextField( InlineTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },

View File

@@ -14,16 +14,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/** /**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted * Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
* in the calendar's (pastelised) colour. Shared by the calendar manager and the * in the calendar's colour — softened to a pastel, or raw when the softener is
* visibility filter so they read identically. * off (issue #36). Shared by the calendar manager and the visibility filter so
* they read identically.
*/ */
@Composable @Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) { fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Box( Box(
modifier = modifier modifier = modifier
.size(40.dp) .size(40.dp)
@@ -34,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
Icon( Icon(
Icons.Filled.CalendarMonth, Icons.Filled.CalendarMonth,
contentDescription = null, contentDescription = null,
tint = pastelize(color, dark), tint = eventFill(color, dark, soften),
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
} }

View File

@@ -18,15 +18,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/** /**
* A wrapping row of round colour swatches; the one matching [selected] is * A wrapping row of round colour swatches; the one matching [selected] is
* ringed and checked. Shared by the calendar editor and the event-colour * ringed and checked. Shared by the calendar editor and the event-colour
* picker so both pick a colour the same way. Swatches render through * picker so both pick a colour the same way. Swatches render through
* [pastelize] — the softened colour the app actually paints, not the raw hue. * [eventFill] — the colour the app actually paints (softened, or raw when the
* softener is off, issue #36), not necessarily the stored hue.
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@@ -37,16 +36,18 @@ fun ColorSwatchRow(
dark: Boolean, dark: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val soften = LocalSoftenColors.current
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) { FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
colors.forEach { argb -> colors.forEach { argb ->
val isSelected = argb == selected val isSelected = argb == selected
val fill = eventFill(argb, dark, soften)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(pastelize(argb, dark)) .background(fill)
.then( .then(
if (isSelected) { if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
@@ -60,7 +61,7 @@ fun ColorSwatchRow(
Icon( Icon(
Icons.Default.Check, Icons.Default.Check,
contentDescription = null, contentDescription = null,
tint = Color.Black.copy(alpha = 0.7f), tint = eventInk(fill, alpha = 0.7f),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
} }

View File

@@ -0,0 +1,46 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import de.jeanlucmakiola.floret.components.pastelize
/**
* Whether calendar/event colours are softened toward theme-fitting pastels
* before display (issue #36). Provided app-wide from the "soften colours"
* setting; the default `true` keeps the historical look. When off, the raw
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
* and other calendar apps. Widgets live outside this composition and read the
* preference directly, then pass the flag to [eventFill] / [eventInk].
*/
val LocalSoftenColors = staticCompositionLocalOf { true }
/**
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
* pastelize also returns an opaque colour).
*/
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
/**
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
* white on a dark fill, near-black on a light one (issue #21). Applies to both
* softened and raw fills — a saturated hue (deep blue, purple, red) is
* perceptually dark even after softening pins its HSV value, so black text on it
* reads poorly. The choice is objective, not a tuned threshold: white wins only
* when it out-contrasts black against the fill, which (by the WCAG contrast
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
* near-black text. [alpha] carries the caller's soft emphasis.
*/
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
}
/**
* Relative-luminance crossover where white text starts to out-contrast black.
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
*/
private const val INK_LUMINANCE_CROSSOVER = 0.179f

View File

@@ -79,7 +79,9 @@ import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -452,9 +454,11 @@ private fun AllDayBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -465,7 +469,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f), color = eventInk(fill),
) )
} }
} }
@@ -616,9 +620,11 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale) minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45 val showTime = block.endMin - block.startMin >= 45
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" }, .semantics { contentDescription = "$title, $timeLabel" },
@@ -629,7 +635,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2, maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
) )
if (showTime) { if (showTime) {
Text( Text(
@@ -637,7 +643,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f), color = eventInk(fill, alpha = 0.6f),
) )
} }
} }

View File

@@ -95,11 +95,12 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -381,7 +382,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
val instance = detail.instance val instance = detail.instance
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val locale = currentDetailLocale() val locale = currentDetailLocale()
val accent = pastelize(instance.color, dark) val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
Column( Column(
modifier = modifier modifier = modifier

View File

@@ -126,6 +126,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
@@ -146,7 +148,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -541,7 +542,8 @@ private fun EventEditContent(
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
// The accent ties the form to the detail screen's design language: the // The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour. // bar under the title takes the target calendar's colour.
val accent = selectedCalendar?.let { pastelize(it.color, dark) } val soften = LocalSoftenColors.current
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
?: MaterialTheme.colorScheme.primary ?: MaterialTheme.colorScheme.primary
val gap = 12.dp val gap = 12.dp
@@ -893,7 +895,7 @@ private fun EventEditContent(
icon = Icons.Default.Palette, icon = Icons.Default.Palette,
iconContentDescription = stringResource(R.string.event_edit_color), iconContentDescription = stringResource(R.string.event_edit_color),
iconTint = if (colorSupported && swatch != null) { iconTint = if (colorSupported && swatch != null) {
pastelize(swatch, dark) eventFill(swatch, dark, soften)
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },

View File

@@ -71,6 +71,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
@@ -79,7 +82,6 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -630,7 +632,7 @@ private fun DayNumberCell(
} }
} }
/** A filled event pill/bar — pastelized fill, title clipped to one line. */ /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
@Composable @Composable
private fun MonthBar( private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance, event: de.jeanlucmakiola.calendula.domain.EventInstance,
@@ -642,6 +644,8 @@ private fun MonthBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val shape = RoundedCornerShape( val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp, topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -650,7 +654,7 @@ private fun MonthBar(
) )
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), shape) .background(fill, shape)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
@@ -660,7 +664,7 @@ private fun MonthBar(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f), color = eventInk(fill),
) )
} }
} }
@@ -673,6 +677,7 @@ private fun OverflowDots(
dark: Boolean, dark: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val soften = LocalSoftenColors.current
Row( Row(
modifier = modifier.height(EVENT_ROW_HEIGHT), modifier = modifier.height(EVENT_ROW_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
@@ -682,7 +687,7 @@ private fun OverflowDots(
Box( Box(
modifier = Modifier modifier = Modifier
.size(6.dp) .size(6.dp)
.background(pastelize(argb, dark), CircleShape), .background(eventFill(argb, dark, soften), CircleShape),
) )
} }
if (extra > 0) { if (extra > 0) {

View File

@@ -55,9 +55,10 @@ import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import java.time.Instant as JavaInstant import java.time.Instant as JavaInstant
import java.time.ZoneId import java.time.ZoneId
@@ -192,6 +193,7 @@ private fun SearchResultRow(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
GroupedRow( GroupedRow(
modifier = modifier, modifier = modifier,
title = event.title, title = event.title,
@@ -203,7 +205,7 @@ private fun SearchResultRow(
modifier = Modifier modifier = Modifier
.size(width = 6.dp, height = 36.dp) .size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)), .background(eventFill(event.color, dark, soften)),
) )
}, },
onClick = onClick, onClick = onClick,

View File

@@ -517,7 +517,7 @@ private fun AppearanceScreen(
} else { } else {
stringResource(R.string.settings_dynamic_color_unavailable) stringResource(R.string.settings_dynamic_color_unavailable)
}, },
position = Position.Bottom, position = Position.Middle,
trailing = { trailing = {
Switch( Switch(
checked = state.dynamicColor, checked = state.dynamicColor,
@@ -531,6 +531,18 @@ private fun AppearanceScreen(
null null
}, },
) )
GroupedRow(
title = stringResource(R.string.settings_soften_colors),
summary = stringResource(R.string.settings_soften_colors_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.softenColors,
onCheckedChange = viewModel::setSoftenColors,
)
},
onClick = { viewModel.setSoftenColors(!state.softenColors) },
)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))

View File

@@ -24,6 +24,8 @@ data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM, val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true, val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true, val dynamicColorAvailable: Boolean = true,
/** Whether raw provider colours are softened to theme-fitting pastels (#36). */
val softenColors: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.Auto, val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */ /** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,

View File

@@ -123,14 +123,17 @@ class SettingsViewModel @Inject constructor(
prefs.showHourLines, prefs.showHourLines,
prefs.showWeekNumbers, prefs.showWeekNumbers,
prefs.agendaShowToday, prefs.agendaShowToday,
::Triple, prefs.softenCalendarColors,
), ) { hourLines, weekNumbers, showToday, soften ->
DisplayToggles(hourLines, weekNumbers, showToday, soften)
},
) { view, screenRange, widgetRange, timeFormat, toggles -> ) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings( ViewSettings(
view, screenRange, widgetRange, timeFormat, view, screenRange, widgetRange, timeFormat,
showHourLines = toggles.first, showHourLines = toggles.showHourLines,
showWeekNumbers = toggles.second, showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.third, agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
) )
}, },
combine( combine(
@@ -155,6 +158,7 @@ class SettingsViewModel @Inject constructor(
showHourLines = views.showHourLines, showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers, showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday, agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
agendaShowRangeBar = misc.showRangeBar, agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle, autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay, pastEventDisplay = misc.pastEventDisplay,
@@ -229,6 +233,14 @@ class SettingsViewModel @Inject constructor(
val showHourLines: Boolean, val showHourLines: Boolean,
val showWeekNumbers: Boolean, val showWeekNumbers: Boolean,
val agendaShowToday: Boolean, val agendaShowToday: Boolean,
val softenColors: Boolean,
)
private data class DisplayToggles(
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
) )
private data class MiscSettings( private data class MiscSettings(
@@ -341,6 +353,19 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDynamicColor(enabled) } viewModelScope.launch { prefs.setDynamicColor(enabled) }
} }
fun setSoftenColors(enabled: Boolean) {
viewModelScope.launch {
prefs.setSoftenCalendarColors(enabled)
// Both widgets paint event colours through the same softener, so a
// change has to redraw them too (they read the flag in their data
// preamble, like is24Hour).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setFont(role: FontRole, token: String) { fun setFont(role: FontRole, token: String) {
viewModelScope.launch { viewModelScope.launch {
when (role) { when (role) {

View File

@@ -84,6 +84,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -98,7 +101,6 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -589,9 +591,11 @@ private fun AllDayBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -602,7 +606,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f), color = eventInk(fill),
) )
} }
} }
@@ -781,9 +785,11 @@ private fun EventBlock(
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" }, .semantics { contentDescription = "$title, $timeLabel" },
@@ -794,7 +800,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines, maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
) )
if (showTime) { if (showTime) {
Text( Text(
@@ -802,7 +808,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f), color = eventInk(fill, alpha = 0.6f),
) )
} }
} }

View File

@@ -60,6 +60,8 @@ sealed interface AgendaWidgetData {
val days: List<AgendaDay>, val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */ /** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean, val is24Hour: Boolean,
/** Whether event colours are softened to pastels, or shown raw (#36). */
val soften: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */ /** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */ /** Saved range pref — the fallback when an instance has no Glance state yet. */
@@ -132,10 +134,12 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val instances = ep.calendarRepository().instances(window).first() val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first() val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this)) .is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready( return AgendaWidgetData.Ready(
today = anchor, today = anchor,
days = groupAgendaDays(anchor, instances, zone), days = groupAgendaDays(anchor, instances, zone),
is24Hour = is24Hour, is24Hour = is24Hour,
soften = soften,
weekStart = weekStart, weekStart = weekStart,
savedRange = savedRange, savedRange = savedRange,
savedPastDisplay = savedPastDisplay, savedPastDisplay = savedPastDisplay,

View File

@@ -56,7 +56,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
@@ -197,6 +197,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
is AgendaRow.Event -> EventRow( is AgendaRow.Event -> EventRow(
event = row.event, event = row.event,
dark = dark, dark = dark,
soften = data.soften,
is24Hour = data.is24Hour, is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM && dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now), row.event.hasEnded(data.now),
@@ -309,12 +310,18 @@ private fun PlaceholderRow(date: LocalDate) {
} }
@Composable @Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) { private fun EventRow(
event: EventInstance,
dark: Boolean,
soften: Boolean,
is24Hour: Boolean,
dimmed: Boolean,
) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) } val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and // Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone. // dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = pastelize(event.color, dark).let { val stripeColor = eventFill(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it if (dimmed) it.copy(alpha = EventDimAlpha) else it
} }
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface

View File

@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget.month
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
@@ -50,14 +49,17 @@ import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import kotlinx.coroutines.flow.first
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -76,9 +78,6 @@ private val LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.dp private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp private val GRID_HPADDING = 8.dp
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
private val EventInk = ColorProvider(Color(0xDE000000))
private fun currentMonthIndex(zone: TimeZone): Int { private fun currentMonthIndex(zone: TimeZone): Int {
val t = today(zone) val t = today(zone)
return t.year * 12 + t.month.ordinal return t.year * 12 + t.month.ordinal
@@ -107,9 +106,12 @@ class MonthWidget : GlanceAppWidget() {
val source = context.loadMonthWidgetSource() val source = context.loadMonthWidgetSource()
val dark = (context.resources.configuration.uiMode and val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
// Read fresh (not through the cached source) so toggling the softener
// redraws with the new choice; it's one cheap DataStore read.
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
provideContent { provideContent {
CalendulaGlanceTheme { CalendulaGlanceTheme {
MonthWidgetBody(source = source, dark = dark) MonthWidgetBody(source = source, dark = dark, soften = soften)
} }
} }
} }
@@ -140,7 +142,7 @@ class ResetMonthAction : ActionCallback {
} }
@Composable @Composable
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) { private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
Column( Column(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
@@ -169,6 +171,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
currentMonth = ym.month, currentMonth = ym.month,
today = source.today, today = source.today,
dark = dark, dark = dark,
soften = soften,
colW = colW, colW = colW,
modifier = GlanceModifier.defaultWeight(), modifier = GlanceModifier.defaultWeight(),
) )
@@ -278,6 +281,7 @@ private fun WeekRow(
currentMonth: Month, currentMonth: Month,
today: LocalDate, today: LocalDate,
dark: Boolean, dark: Boolean,
soften: Boolean,
colW: Dp, colW: Dp,
modifier: GlanceModifier, modifier: GlanceModifier,
) { ) {
@@ -297,7 +301,7 @@ private fun WeekRow(
// One lane row per event row. A multi-day span is a single Box spanning // One lane row per event row. A multi-day span is a single Box spanning
// its columns (colW * n) so it's connected with no seam and rounded ends. // its columns (colW * n) so it's connected with no seam and rounded ends.
repeat(MAX_LANES) { lane -> repeat(MAX_LANES) { lane ->
LaneRow(week = week, lane = lane, dark = dark, colW = colW) LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp)) Spacer(GlanceModifier.height(1.dp))
} }
OverflowRow(week = week, colW = colW) OverflowRow(week = week, colW = colW)
@@ -346,7 +350,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
} }
@Composable @Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
val context = LocalContext.current val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0 var col = 0
@@ -354,12 +358,12 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
if (span != null) { if (span != null) {
val cols = span.endCol - col + 1 val cols = span.endCol - col + 1
SpanBar(event = span.event, dark = dark, width = colW * cols) SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
col = span.endCol + 1 col = span.endCol + 1
} else { } else {
val timed = timedEventAt(week, lane, col, week.days[col]) val timed = timedEventAt(week, lane, col, week.days[col])
if (timed != null) { if (timed != null) {
SpanBar(event = timed, dark = dark, width = colW) SpanBar(event = timed, dark = dark, soften = soften, width = colW)
} else { } else {
// Empty lane cell: a tap opens that day, so blank space in a // Empty lane cell: a tap opens that day, so blank space in a
// day column is a day-open target just like the number is. // day column is a day-open target just like the number is.
@@ -378,8 +382,9 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
/** A single connected, rounded event bar [width] wide with its clipped title. */ /** A single connected, rounded event bar [width] wide with its clipped title. */
@Composable @Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) { private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
val context = LocalContext.current val context = LocalContext.current
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(width) .width(width)
@@ -402,13 +407,13 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
.cornerRadius(4.dp) .cornerRadius(4.dp)
.background(pastelize(event.color, dark)), .background(fill),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
Text( Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) }, text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1, maxLines = 1,
style = TextStyle(color = EventInk, fontSize = 9.sp), style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
modifier = GlanceModifier.padding(horizontal = 3.dp), modifier = GlanceModifier.padding(horizontal = 3.dp),
) )
} }

View File

@@ -293,6 +293,8 @@
<string name="settings_default_view">Default view</string> <string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string> <string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string> <string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_soften_colors">Soften calendar colours</string>
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
<string name="settings_font_headings">Headings font</string> <string name="settings_font_headings">Headings font</string>
<string name="settings_font_body">Body font</string> <string name="settings_font_body">Body font</string>
<string name="settings_font_system">System default</string> <string name="settings_font_system">System default</string>