Compare commits

..

4 Commits

Author SHA1 Message Date
5be5ff77d0 Merge pull request 'Release 2.15.0' (!79) from release/v2.15.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Successful in 13m40s
2026-07-15 18:54:02 +00:00
6519cbca79 release: cut 2.15.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 11m17s
Move the Unreleased changelog under [2.15.0] — 2026-07-15, bump
versionName/versionCode to 2.15.0/21500, and sync the F-Droid
per-version changelog.

Milestone 2.15.0 (all integrated): #21 #35 #36 #39 #40 #46 #52.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:34:52 +02:00
50510a23da 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>
2026-07-15 20:26:34 +02:00
2279738371 docs(changelog): note the new French and Polish translations
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:42:19 +02:00
23 changed files with 306 additions and 63 deletions

View File

@@ -5,9 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.15.0] — 2026-07-15
### 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 →
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
@@ -44,6 +55,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
editor if you want it). Duplicate works from read-only calendars too, dropping
the copy into a writable one. Thanks to @internet-rando for the suggestion
([#52]).
- French and Polish, in early form. Calendula has started speaking French and
Polish, both contributed as community translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are partway there, so untranslated parts still show in English until they
fill out — you can already pick either under Settings → Language or in Android's
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
(Polish) for getting them started; help finishing them is very welcome.
### Fixed
- Reminders for events on another day no longer read as if they were today. A
@@ -964,10 +982,12 @@ automatically, with zero telemetry and no internet permission.
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21401
versionName = "2.14.1"
versionCode = 21500
versionName = "2.15.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

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

View File

@@ -114,6 +114,21 @@ class SettingsPrefs @Inject constructor(
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
* strings — "system", "custom", or a bundled font's token — resolved to a
@@ -679,6 +694,7 @@ class SettingsPrefs @Inject constructor(
companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
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 PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
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.hasEnded
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.floret.identity.animateItemMotion
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.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -454,6 +455,7 @@ private fun AgendaEventRow(
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
@@ -466,7 +468,7 @@ private fun AgendaEventRow(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
.background(eventFill(event.color, dark, soften)),
)
},
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.data.calendar.CalendarColorPalette
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.SourceLogo
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.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import java.time.LocalDate
/** 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 confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Scaffold(
modifier = Modifier
@@ -624,7 +626,7 @@ private fun CalendarEditor(
.padding(horizontal = 16.dp, vertical = 8.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(
value = name,
onValueChange = { name = it },

View File

@@ -14,16 +14,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
* visibility filter so they read identically.
* in the calendar's colour — softened to a pastel, or raw when the softener is
* off (issue #36). Shared by the calendar manager and the visibility filter so
* they read identically.
*/
@Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
Box(
modifier = modifier
.size(40.dp)
@@ -34,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
tint = pastelize(color, dark),
tint = eventFill(color, dark, soften),
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.pastelize
/**
* 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.
* [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)
@Composable
@@ -37,16 +36,18 @@ fun ColorSwatchRow(
dark: Boolean,
modifier: Modifier = Modifier,
) {
val soften = LocalSoftenColors.current
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
colors.forEach { argb ->
val isSelected = argb == selected
val fill = eventFill(argb, dark, soften)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(vertical = 4.dp)
.size(40.dp)
.clip(CircleShape)
.background(pastelize(argb, dark))
.background(fill)
.then(
if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
@@ -60,7 +61,7 @@ fun ColorSwatchRow(
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.Black.copy(alpha = 0.7f),
tint = eventInk(fill, alpha = 0.7f),
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.floret.identity.rememberReduceMotion
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.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -452,9 +454,11 @@ private fun AllDayBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
@@ -465,7 +469,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
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)}" +
minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box(
modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
@@ -629,7 +635,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
color = eventInk(fill, alpha = 0.85f),
)
if (showTime) {
Text(
@@ -637,7 +643,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
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.floret.identity.predictiveBack
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.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
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.reminderLeadTimeLabel
import kotlinx.coroutines.launch
@@ -381,7 +382,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
val instance = detail.instance
val dark = isSystemInDarkTheme()
val locale = currentDetailLocale()
val accent = pastelize(instance.color, dark)
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
Column(
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.CalendarDatePickerDialog
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.DialogUnitDropdown
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.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -541,7 +542,8 @@ private fun EventEditContent(
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
// The accent ties the form to the detail screen's design language: the
// 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
val gap = 12.dp
@@ -893,7 +895,7 @@ private fun EventEditContent(
icon = Icons.Default.Palette,
iconContentDescription = stringResource(R.string.event_edit_color),
iconTint = if (colorSupported && swatch != null) {
pastelize(swatch, dark)
eventFill(swatch, dark, soften)
} else {
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.EventDimAlpha
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.ViewSwitcherPill
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.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.launch
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
private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance,
@@ -642,6 +644,8 @@ private fun MonthBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -650,7 +654,7 @@ private fun MonthBar(
)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), shape)
.background(fill, shape)
.padding(horizontal = 4.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
@@ -660,7 +664,7 @@ private fun MonthBar(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
color = eventInk(fill),
)
}
}
@@ -673,6 +677,7 @@ private fun OverflowDots(
dark: Boolean,
modifier: Modifier = Modifier,
) {
val soften = LocalSoftenColors.current
Row(
modifier = modifier.height(EVENT_ROW_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(2.dp),
@@ -682,7 +687,7 @@ private fun OverflowDots(
Box(
modifier = Modifier
.size(6.dp)
.background(pastelize(argb, dark), CircleShape),
.background(eventFill(argb, dark, soften), CircleShape),
)
}
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.Position
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.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import java.time.Instant as JavaInstant
import java.time.ZoneId
@@ -192,6 +193,7 @@ private fun SearchResultRow(
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
GroupedRow(
modifier = modifier,
title = event.title,
@@ -203,7 +205,7 @@ private fun SearchResultRow(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(event.color, dark)),
.background(eventFill(event.color, dark, soften)),
)
},
onClick = onClick,

View File

@@ -517,7 +517,7 @@ private fun AppearanceScreen(
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Bottom,
position = Position.Middle,
trailing = {
Switch(
checked = state.dynamicColor,
@@ -531,6 +531,18 @@ private fun AppearanceScreen(
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))

View File

@@ -24,6 +24,8 @@ data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: 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,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,

View File

@@ -123,14 +123,17 @@ class SettingsViewModel @Inject constructor(
prefs.showHourLines,
prefs.showWeekNumbers,
prefs.agendaShowToday,
::Triple,
),
prefs.softenCalendarColors,
) { hourLines, weekNumbers, showToday, soften ->
DisplayToggles(hourLines, weekNumbers, showToday, soften)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = toggles.first,
showWeekNumbers = toggles.second,
agendaShowToday = toggles.third,
showHourLines = toggles.showHourLines,
showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
)
},
combine(
@@ -155,6 +158,7 @@ class SettingsViewModel @Inject constructor(
showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -229,6 +233,14 @@ class SettingsViewModel @Inject constructor(
val showHourLines: Boolean,
val showWeekNumbers: 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(
@@ -341,6 +353,19 @@ class SettingsViewModel @Inject constructor(
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) {
viewModelScope.launch {
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.EventDimAlpha
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.rememberCurrentMinute
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.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -589,9 +591,11 @@ private fun AllDayBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
@@ -602,7 +606,7 @@ private fun AllDayBar(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
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 dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
Box(
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)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
@@ -794,7 +800,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
color = eventInk(fill, alpha = 0.85f),
)
if (showTime) {
Text(
@@ -802,7 +808,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
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>,
/** Resolved clock convention for event time labels (the time-format pref). */
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. */
val weekStart: DayOfWeek,
/** 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 is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, instances, zone),
is24Hour = is24Hour,
soften = soften,
weekStart = weekStart,
savedRange = savedRange,
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.formatTimeOfDay
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.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
@@ -197,6 +197,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
is AgendaRow.Event -> EventRow(
event = row.event,
dark = dark,
soften = data.soften,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
@@ -309,12 +310,18 @@ private fun PlaceholderRow(date: LocalDate) {
}
@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 title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// 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.
val stripeColor = pastelize(event.color, dark).let {
val stripeColor = eventFill(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
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.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.intPreferencesKey
@@ -50,14 +49,17 @@ import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
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.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.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import kotlinx.coroutines.flow.first
import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -76,9 +78,6 @@ private val LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.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 {
val t = today(zone)
return t.year * 12 + t.month.ordinal
@@ -107,9 +106,12 @@ class MonthWidget : GlanceAppWidget() {
val source = context.loadMonthWidgetSource()
val dark = (context.resources.configuration.uiMode and
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 {
CalendulaGlanceTheme {
MonthWidgetBody(source = source, dark = dark)
MonthWidgetBody(source = source, dark = dark, soften = soften)
}
}
}
@@ -140,7 +142,7 @@ class ResetMonthAction : ActionCallback {
}
@Composable
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
Column(
modifier = GlanceModifier
.fillMaxSize()
@@ -169,6 +171,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
currentMonth = ym.month,
today = source.today,
dark = dark,
soften = soften,
colW = colW,
modifier = GlanceModifier.defaultWeight(),
)
@@ -278,6 +281,7 @@ private fun WeekRow(
currentMonth: Month,
today: LocalDate,
dark: Boolean,
soften: Boolean,
colW: Dp,
modifier: GlanceModifier,
) {
@@ -297,7 +301,7 @@ private fun WeekRow(
// 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.
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))
}
OverflowRow(week = week, colW = colW)
@@ -346,7 +350,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
}
@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
Row(modifier = GlanceModifier.fillMaxWidth()) {
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 }
if (span != null) {
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
} else {
val timed = timedEventAt(week, lane, col, week.days[col])
if (timed != null) {
SpanBar(event = timed, dark = dark, width = colW)
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
} else {
// 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.
@@ -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. */
@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 fill = eventFill(event.color, dark, soften)
Box(
modifier = GlanceModifier
.width(width)
@@ -402,13 +407,13 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(4.dp)
.background(pastelize(event.color, dark)),
.background(fill),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(color = EventInk, fontSize = 9.sp),
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
modifier = GlanceModifier.padding(horizontal = 3.dp),
)
}

View File

@@ -293,6 +293,8 @@
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</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_body">Body font</string>
<string name="settings_font_system">System default</string>

View File

@@ -0,0 +1,74 @@
### 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 →
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
after exactly the delay you want instead of only a preset one ([#40]).
- Move an event to another calendar. When editing an existing event, the
calendar row is now tappable — pick a different calendar and saving moves the
event across, instead of having to delete it and recreate it elsewhere.
Recurring series move as a whole, keeping their individually-edited and
cancelled occurrences, and any reminders and guests come along too. A calendar
can't simply be reassigned underneath an event, so Calendula recreates it on
the target and removes the original — the same approach other calendar apps
take. Thanks to @prismplex for the suggestion ([#39]).
- Open an event straight into the edit form from another app. Calendula already
answered the "new event" and "open this event" hand-offs from other apps and
widgets; it now also answers the "edit this event" one, so an assistant, task
app, or widget can send an existing event to Calendula and land on its edit
screen rather than the read-only details. A hand-off with no event attached
opens the same prefilled create form as "new event". Calendula also recognises
a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too.
- Keep today at the top of the agenda. A new **Always show today** setting
(Settings → Agenda, on by default) anchors today as the first entry in both the
Agenda screen and its home-screen widget even once nothing is left today —
under today's header a "No more events today" note appears — so the first
events you see are clearly today's rather than a future day's. Turn it off to
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
- Duplicate an event. The event details now carry a **Duplicate** action that
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
a one-off like a shift or an appointment can be recreated by just changing the
day and time instead of re-typing every field. The copy keeps the original's
time, and its title, location, notes, colour, guests and reminders come along;
it's saved as its own single event (any repeat is left off — add one in the
editor if you want it). Duplicate works from read-only calendars too, dropping
the copy into a writable one. Thanks to @internet-rando for the suggestion
([#52]).
- French and Polish, in early form. Calendula has started speaking French and
Polish, both contributed as community translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are partway there, so untranslated parts still show in English until they
fill out — you can already pick either under Settings → Language or in Android's
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
(Polish) for getting them started; help finishing them is very welcome.
### Fixed
- Reminders for events on another day no longer read as if they were today. A
reminder fired ahead of time — say, the day before — used to show only the
event's time, making it look like it was happening now. The notification now
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
week, or the date for anything further out. Thanks to @moonj for the report
([#46]).
- Agenda dates now read in your locale's format. The agenda's range bar and its
day headers used a fixed day-month-year layout — and the range span even mixed
two orders (e.g. "15 Jul Aug 13, 2026") — instead of following your language's
conventions. Dates across the agenda and its widget now use your locale's own
field order, matching the rest of the app. The range bar also no longer repeats
the range's name from the selector button beside it, showing just the dates.
- Calendar gutters line up with the menu button. The Month view's week-number
column, and the Week and Day views' hour labels, sat a few pixels left of the
hamburger menu above them; they now line up with it. In Month view the day
cells also sit squarely under their weekday letters.