Compare commits
3 Commits
fix/180-de
...
c1dc725b2c
| Author | SHA1 | Date | |
|---|---|---|---|
| c1dc725b2c | |||
| 9287b7b0da | |||
| 37995d1fb3 |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Week and day view can be set to show more or less of the day at once, under
|
||||||
|
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
|
||||||
|
hour to your screen so all 24 hours are visible without scrolling — on a tall
|
||||||
|
phone the old fixed spacing showed only about half a day, so appointments
|
||||||
|
could sit below the fold all week. Compact and Comfortable are fixed steps
|
||||||
|
either side of the previous spacing, which stays the default. Both views share
|
||||||
|
the setting ([#56]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Event blocks now only draw text they can draw whole. One too short for a full
|
||||||
|
line shows no title rather than a sliced one, a block that cannot fit both its
|
||||||
|
title and its time keeps the title, and a block too narrow to hold more than a
|
||||||
|
syllable stays on one ellipsised line instead of stacking letters down the
|
||||||
|
block. Tapping and the spoken description are unchanged ([#56]).
|
||||||
|
- Calendar colours are reworked so text on an event is always readable. They
|
||||||
|
were shaped by a brightness setting that does not match what the eye sees, so
|
||||||
|
whether a block got dark or light text depended on which hue you happened to
|
||||||
|
pick — an orange calendar took dark text while a red one beside it took light
|
||||||
|
— and colours landing in between were hard to read either way. Each colour now
|
||||||
|
keeps its hue and is moved clear of that middle: most become deep blocks with
|
||||||
|
light text, while naturally pale colours such as yellow stay pale and take
|
||||||
|
dark text, rather than being forced into a muddy brown. Dots, stripes and
|
||||||
|
icons are tuned separately from blocks, so they stay visible against the
|
||||||
|
background instead of sharing a colour meant to sit behind text. The setting
|
||||||
|
is now called **Harmonise calendar colours**; turning it off still shows the
|
||||||
|
raw colours from your calendar source ([#21], [#36]).
|
||||||
|
|
||||||
## [2.17.1] — 2026-07-30
|
## [2.17.1] — 2026-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1253,3 +1281,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||||
|
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||||
|
|||||||
@@ -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.LocalTimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
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
|
||||||
@@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
|
LocalTimelineScale provides settings.timelineScale,
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
@@ -270,6 +271,18 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
|
||||||
|
* [TimelineScale.Regular] — the historical 56dp scale.
|
||||||
|
*/
|
||||||
|
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
|
||||||
|
prefs[TIMELINE_SCALE_KEY].toEnum(TimelineScale.Regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTimelineScale(scale: TimelineScale) {
|
||||||
|
store.edit { it[TIMELINE_SCALE_KEY] = scale.name }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
@@ -808,6 +821,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||||
|
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import kotlin.math.abs
|
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
||||||
import kotlin.math.atan2
|
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||||
import kotlin.math.cbrt
|
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||||
import kotlin.math.hypot
|
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curates an account's published event palette for the colour picker.
|
* Curates an account's published event palette for the colour picker.
|
||||||
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
|
|||||||
* (#22).
|
* (#22).
|
||||||
*
|
*
|
||||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
* the raw provider value — and it gets that colour from the same [eventTone]
|
||||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
* the picker calls, rather than from a copy of its shaping kept in step by hand.
|
||||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
* Because a harmonised container pins lightness, the raw palette's lightness
|
||||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
* axis is invisible on screen: two raw colours that look different — a navy and
|
||||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
|
||||||
* space, as before, left near-identical painted swatches and stranded the
|
* paints as the same grey. Judging distinctness in raw space, as before, left
|
||||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
* near-identical painted swatches and stranded the neutrals as a run of
|
||||||
|
* look-alike tints at the end of the grid.
|
||||||
*
|
*
|
||||||
* Three steps, all in painted space:
|
* Three steps, all in painted space:
|
||||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||||
* hue, and all the neutrals together.
|
* hue, and all the neutrals together.
|
||||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
|
||||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
* swatches — with lightness pinned, a grey source paints as plain grey, so
|
||||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
* "has no hue left" is simply zero chroma — and are then thinned to visually
|
||||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
* distinct colours: most vivid first, a colour is kept only when at least
|
||||||
* colour already kept. Small palettes are already curated by their adapter
|
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
|
||||||
* and pass through whole.
|
* palettes are already curated by their adapter and pass through whole.
|
||||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||||
* seam lands in dead space and no hue family is torn across both ends.
|
* seam lands in dead space and no hue family is torn across both ends.
|
||||||
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
|
|||||||
*/
|
*/
|
||||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
val painted = sortedBy { it.key }
|
val painted = sortedBy { it.key }
|
||||||
.distinctBy { pastelArgb(it.argb) }
|
.distinctBy { paintedArgb(it.argb) }
|
||||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
.map { it to oklchOf(paintedArgb(it.argb)) }
|
||||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||||
painted
|
painted
|
||||||
} else {
|
} else {
|
||||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||||
}
|
}
|
||||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||||
}
|
}
|
||||||
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
|||||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||||
*/
|
*/
|
||||||
private fun orderAroundWheel(
|
private fun orderAroundWheel(
|
||||||
swatches: List<Pair<EventColorOption, Lab>>,
|
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||||
): List<Pair<EventColorOption, Lab>> {
|
): List<Pair<EventColorOption, Oklch>> {
|
||||||
if (swatches.size < 2) return swatches
|
if (swatches.size < 2) return swatches
|
||||||
val byHue = swatches.sortedWith(
|
val byHue = swatches.sortedWith(
|
||||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
|
||||||
)
|
)
|
||||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||||
var cutAfter = byHue.lastIndex
|
var cutAfter = byHue.lastIndex
|
||||||
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
|
||||||
for (i in 0 until byHue.lastIndex) {
|
for (i in 0 until byHue.lastIndex) {
|
||||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||||
if (gap > widestGap) {
|
if (gap > widestGap) {
|
||||||
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
|
|||||||
|
|
||||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||||
private fun thin(
|
private fun thin(
|
||||||
swatches: List<Pair<EventColorOption, Lab>>,
|
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||||
): List<Pair<EventColorOption, Lab>> {
|
): List<Pair<EventColorOption, Oklch>> {
|
||||||
val byVividness = swatches
|
val byVividness = swatches
|
||||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
.sortedWith(
|
||||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
||||||
|
.thenBy { it.first.key },
|
||||||
|
)
|
||||||
|
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
||||||
for (candidate in byVividness) {
|
for (candidate in byVividness) {
|
||||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
||||||
}
|
}
|
||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The colour the picker paints for [argb]; the light theme stands in as the
|
||||||
* The softening the colour picker paints over every swatch: keep the hue, scale
|
* reference, since a harmonised container differs only in lightness by theme
|
||||||
* and clamp saturation into a gentle band, and pin value to a constant so
|
* and curation compares hue and chroma. */
|
||||||
* nothing screams and everything reads on the surface. Value is fixed here so
|
private fun paintedArgb(argb: Int): Int =
|
||||||
* curation is theme-independent — only hue and saturation distinguish painted
|
eventTone(argb, dark = false, harmonise = true).container
|
||||||
* swatches.
|
|
||||||
*
|
|
||||||
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
|
||||||
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
|
||||||
* rather than theme-picked. Curation must reason about the colour the picker
|
|
||||||
* paints, so the two shapings have to agree: if floret's saturation band or
|
|
||||||
* curve changes, update this in step.
|
|
||||||
*/
|
|
||||||
fun pastelArgb(rawArgb: Int): Int {
|
|
||||||
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
|
||||||
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
|
||||||
val b = (rawArgb and 0xFF) / 255f
|
|
||||||
val max = maxOf(r, g, b)
|
|
||||||
val min = minOf(r, g, b)
|
|
||||||
val delta = max - min
|
|
||||||
val hue = when {
|
|
||||||
delta == 0f -> 0f
|
|
||||||
max == r -> 60f * (((g - b) / delta) % 6f)
|
|
||||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
|
||||||
else -> 60f * (((r - g) / delta) + 4f)
|
|
||||||
}.let { if (it < 0f) it + 360f else it }
|
|
||||||
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
|
||||||
val s = sat.coerceIn(0.25f, 0.65f)
|
|
||||||
val v = PASTEL_VALUE
|
|
||||||
val c = v * s
|
|
||||||
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
|
||||||
val m = v - c
|
|
||||||
val (rr, gg, bb) = when {
|
|
||||||
hue < 60f -> Triple(c, x, 0f)
|
|
||||||
hue < 120f -> Triple(x, c, 0f)
|
|
||||||
hue < 180f -> Triple(0f, c, x)
|
|
||||||
hue < 240f -> Triple(0f, x, c)
|
|
||||||
hue < 300f -> Triple(x, 0f, c)
|
|
||||||
else -> Triple(c, 0f, x)
|
|
||||||
}
|
|
||||||
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
|
||||||
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
|
||||||
private const val PASTEL_VALUE = 0.82f
|
|
||||||
|
|
||||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||||
private const val CURATION_TRIGGER_SIZE = 36
|
private const val CURATION_TRIGGER_SIZE = 36
|
||||||
|
|
||||||
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
|
||||||
private const val MIN_DELTA_E = 13.0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
* Minimum Oklab distance between surviving painted swatches. Painted colours all
|
||||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
* share one lightness, so this is really a hue/chroma separation — far enough
|
||||||
* is dropped rather than shown as pale filler.
|
* apart that two swatches never read as the same colour in the grid.
|
||||||
*/
|
*/
|
||||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
private const val MIN_DISTANCE = 0.025f
|
||||||
|
|
||||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
|
||||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
|
||||||
val chroma: Double get() = hypot(a, b)
|
|
||||||
|
|
||||||
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
|
||||||
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
|
||||||
|
|
||||||
fun deltaE(other: Lab): Double =
|
|
||||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun of(argb: Int): Lab {
|
|
||||||
fun linear(shift: Int): Double {
|
|
||||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
|
||||||
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
|
||||||
}
|
|
||||||
val r = linear(16)
|
|
||||||
val g = linear(8)
|
|
||||||
val b = linear(0)
|
|
||||||
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
|
||||||
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
||||||
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
|
||||||
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
|
||||||
val fy = f(y)
|
|
||||||
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colours one calendar's identity resolves to in one theme.
|
||||||
|
*
|
||||||
|
* The app paints a calendar's colour in two structurally different places, and
|
||||||
|
* they want opposite things:
|
||||||
|
*
|
||||||
|
* - a **[container]** sits behind text (week and day blocks, month bars, widget
|
||||||
|
* rows, picker swatches), so it has to contrast with its own ink;
|
||||||
|
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
|
||||||
|
* search stripes, calendar icon tints, the detail header), so it has to
|
||||||
|
* contrast with the *surface* instead.
|
||||||
|
*
|
||||||
|
* Painting both from one colour is what made this hard to get right: deep enough
|
||||||
|
* to carry white text is too dark to see as a dot on a dark surface, and light
|
||||||
|
* enough to show up there is too pale to carry white text. Splitting them lets
|
||||||
|
* each be pinned to the lightness its job needs, while hue and chroma — the parts
|
||||||
|
* that actually say *which calendar this is* — stay shared, so the two roles
|
||||||
|
* still read as the same colour.
|
||||||
|
*/
|
||||||
|
data class EventTone(
|
||||||
|
val container: Int,
|
||||||
|
val onContainer: Int,
|
||||||
|
val accent: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve [rawArgb] for the current theme.
|
||||||
|
*
|
||||||
|
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
|
||||||
|
* role, which is what makes the ink predictable: every container lands at the
|
||||||
|
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
|
||||||
|
* it off the provider's colour is painted verbatim — the sync source's own look,
|
||||||
|
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
|
||||||
|
* nothing constrains what the provider sends.
|
||||||
|
*/
|
||||||
|
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
|
||||||
|
val opaque = rawArgb or 0xFF000000.toInt()
|
||||||
|
val raw = oklchOf(opaque)
|
||||||
|
if (!harmonise) {
|
||||||
|
return EventTone(
|
||||||
|
container = opaque,
|
||||||
|
onContainer = inkFor(raw.lightness),
|
||||||
|
accent = opaque,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A near-grey source has no hue worth keeping, so it stays grey rather than
|
||||||
|
// being pushed to an arbitrary one; everything else is held inside a band
|
||||||
|
// that keeps calendars apart without going neon.
|
||||||
|
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
|
||||||
|
// Two poles rather than one. Forcing every hue deep is what turned the warm,
|
||||||
|
// naturally light ones muddy — a dark orange is brown and a dark yellow is
|
||||||
|
// olive, which is a fact about those hues, not a tuning miss. So a colour is
|
||||||
|
// sent to whichever pole it already sits nearer: most land deep and carry
|
||||||
|
// white ink, while genuinely light sources (yellows, creams, pale tints)
|
||||||
|
// stay light and carry dark ink, keeping the character that made them
|
||||||
|
// recognisable. Either way the colour is pulled clear of the middle, where
|
||||||
|
// neither ink reads well — which was the original fault.
|
||||||
|
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
|
||||||
|
val containerLightness = when {
|
||||||
|
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
|
||||||
|
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
|
||||||
|
dark -> CONTAINER_LIGHTNESS_DARK
|
||||||
|
else -> CONTAINER_LIGHTNESS_LIGHT
|
||||||
|
}
|
||||||
|
// The accent takes no pole: it is a mark on the app's surface, so it has to
|
||||||
|
// contrast with that surface whichever way its container went.
|
||||||
|
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
|
||||||
|
return EventTone(
|
||||||
|
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
|
||||||
|
onContainer = inkFor(containerLightness),
|
||||||
|
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* White or black ink for a background of the given perceptual [lightness].
|
||||||
|
*
|
||||||
|
* Derived rather than hardcoded so it stays right for raw provider colours,
|
||||||
|
* where lightness is whatever the sync source sent. For harmonised containers it
|
||||||
|
* is constant by construction — that is the point of pinning the lightness.
|
||||||
|
*/
|
||||||
|
fun inkFor(lightness: Float): Int =
|
||||||
|
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightness at which white ink overtakes black. Sits above the midpoint because
|
||||||
|
* lightness is perceptual: a colour has to be distinctly light before black wins.
|
||||||
|
*/
|
||||||
|
const val INK_FLIP_LIGHTNESS = 0.62f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw lightness at or above which a colour keeps its light character instead of
|
||||||
|
* being pushed deep. Set high enough that oranges and warm reds still go deep —
|
||||||
|
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
|
||||||
|
* so only the genuinely pale sources take the light pole.
|
||||||
|
*/
|
||||||
|
const val LIGHT_POLE_THRESHOLD = 0.72f
|
||||||
|
|
||||||
|
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
|
||||||
|
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
|
||||||
|
|
||||||
|
/** Same in dark mode, a shade lighter so a block separates from the surface. */
|
||||||
|
const val CONTAINER_LIGHTNESS_DARK = 0.48f
|
||||||
|
|
||||||
|
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
|
||||||
|
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
|
||||||
|
|
||||||
|
/** Same in dark mode, held down a little so a pale block doesn't glare. */
|
||||||
|
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
|
||||||
|
|
||||||
|
/** Accent lightness: dark enough to read as a mark on a pale surface. */
|
||||||
|
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
|
||||||
|
|
||||||
|
/** Same against a dark surface, where the mark has to be the light one. */
|
||||||
|
const val ACCENT_LIGHTNESS_DARK = 0.78f
|
||||||
|
|
||||||
|
/** Below this chroma a colour counts as grey and keeps no hue. */
|
||||||
|
const val GREY_CHROMA = 0.02f
|
||||||
|
|
||||||
|
/** Chroma band for harmonised colours: distinct, but never electric. */
|
||||||
|
const val MIN_CHROMA = 0.07f
|
||||||
|
const val MAX_CHROMA = 0.16f
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.color
|
||||||
|
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cbrt
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
|
||||||
|
* (colourfulness) and [hue] in degrees.
|
||||||
|
*
|
||||||
|
* The point of using it over HSV is that its lightness axis matches what the eye
|
||||||
|
* calls brightness. HSV's "value" does not: at a single pinned value the hues
|
||||||
|
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
|
||||||
|
* pinning value produced fills that needed different ink per hue. Pin Oklch
|
||||||
|
* lightness instead and every hue lands at the same apparent brightness, so one
|
||||||
|
* ink serves all of them.
|
||||||
|
*
|
||||||
|
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
|
||||||
|
* the sRGB limit.
|
||||||
|
*/
|
||||||
|
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
|
||||||
|
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
|
||||||
|
* saturated blues).
|
||||||
|
*/
|
||||||
|
fun distanceTo(other: Oklch): Float {
|
||||||
|
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
|
||||||
|
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
|
||||||
|
val dl = (lightness - other.lightness).toDouble()
|
||||||
|
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read [argb]'s opaque colour as Oklch. */
|
||||||
|
fun oklchOf(argb: Int): Oklch {
|
||||||
|
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
|
||||||
|
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
|
||||||
|
val b = toLinear((argb and 0xFF) / 255.0)
|
||||||
|
|
||||||
|
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
||||||
|
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
||||||
|
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
||||||
|
|
||||||
|
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
|
||||||
|
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
|
||||||
|
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
|
||||||
|
|
||||||
|
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
|
||||||
|
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
|
||||||
|
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
|
||||||
|
* and gives up chroma until it does. Holding lightness is what matters here —
|
||||||
|
* it's the axis the contrast guarantees rest on.
|
||||||
|
*/
|
||||||
|
fun Oklch.toArgb(): Int {
|
||||||
|
val fitted = if (inSrgb(lightness, chroma, hue)) {
|
||||||
|
chroma
|
||||||
|
} else {
|
||||||
|
var low = 0f
|
||||||
|
var high = chroma
|
||||||
|
repeat(GAMUT_STEPS) {
|
||||||
|
val mid = (low + high) / 2f
|
||||||
|
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
|
||||||
|
}
|
||||||
|
low
|
||||||
|
}
|
||||||
|
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
|
||||||
|
return 0xFF shl 24 or
|
||||||
|
(channel(r) shl 16) or
|
||||||
|
(channel(g) shl 8) or
|
||||||
|
channel(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
|
||||||
|
val a = chroma * cosDeg(hue)
|
||||||
|
val b = chroma * sinDeg(hue)
|
||||||
|
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
|
||||||
|
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
|
||||||
|
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
|
||||||
|
return Triple(
|
||||||
|
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
|
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
|
||||||
|
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
|
||||||
|
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||||
|
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||||
|
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channel(linear: Double): Int =
|
||||||
|
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
|
||||||
|
|
||||||
|
private fun toLinear(c: Double): Double =
|
||||||
|
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||||
|
|
||||||
|
private fun toSrgb(c: Double): Double =
|
||||||
|
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
|
||||||
|
|
||||||
|
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
|
||||||
|
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
|
||||||
|
|
||||||
|
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
|
||||||
|
private const val GAMUT_STEPS = 24
|
||||||
|
|
||||||
|
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
|
||||||
|
private const val GAMUT_EPSILON = 1e-4
|
||||||
@@ -31,6 +31,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
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.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
@@ -137,7 +138,7 @@ internal 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(eventFill(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
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
|
||||||
@@ -442,7 +443,7 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.CalendarMonth,
|
Icons.Filled.CalendarMonth,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = eventFill(color, dark, soften),
|
tint = eventAccent(color, dark, soften),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,45 +2,53 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.domain.color.inkFor
|
||||||
|
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
|
||||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
* per role — before display (issue #36). Provided app-wide from the setting; the
|
||||||
* setting; the default `true` keeps the historical look. When off, the raw
|
* default `true` is the app's own look. When off, the raw provider colour is
|
||||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
|
||||||
* and other calendar apps. Widgets live outside this composition and read the
|
* apps. Widgets live outside this composition and read the preference directly,
|
||||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
* then pass the flag through.
|
||||||
*/
|
*/
|
||||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
* Fill for a surface that carries text on it — a week/day block, a month bar, a
|
||||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
* widget row, a picker swatch. Pair it with [eventInk].
|
||||||
* pastelize also returns an opaque colour).
|
|
||||||
*/
|
*/
|
||||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
Color(eventTone(rawArgb, dark, soften).container)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
|
||||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
|
||||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
* pinned to contrast with the surface instead of with ink.
|
||||||
* 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 {
|
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
Color(eventTone(rawArgb, dark, soften).accent)
|
||||||
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relative-luminance crossover where white text starts to out-contrast black.
|
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
|
||||||
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
*
|
||||||
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
* Takes the fill rather than the raw colour so it stays correct for the raw
|
||||||
|
* (un-harmonised) path too, where the provider decides the lightness.
|
||||||
*/
|
*/
|
||||||
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
|
||||||
|
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ink alpha for a block's secondary line (the time under the title). Held above
|
||||||
|
* a fainter value because harmonised containers are deep: the small time text
|
||||||
|
* needs to stay clear of the 4.5:1 WCAG asks of body text.
|
||||||
|
*/
|
||||||
|
const val SECONDARY_INK_ALPHA = 0.8f
|
||||||
|
|
||||||
|
private fun Color.toArgbInt(): Int =
|
||||||
|
(0xFF shl 24) or
|
||||||
|
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
|
||||||
|
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
|
||||||
|
(blue * 255f).toInt().coerceIn(0, 255)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How tall one hour is drawn in the week and day timelines (#56).
|
||||||
|
*
|
||||||
|
* One shared setting for both views: they are the same grid at different widths,
|
||||||
|
* and a scale that only applied to one of them would read as a bug.
|
||||||
|
*
|
||||||
|
* [FitDay] is the answer to the actual complaint behind the issue — on a tall
|
||||||
|
* phone the default scale shows about half a day, so a whole week can hide
|
||||||
|
* appointments below the fold. It derives the hour height from the timeline's
|
||||||
|
* own viewport instead of a fixed value.
|
||||||
|
*/
|
||||||
|
enum class TimelineScale {
|
||||||
|
/** Whole day in one screen: the hour height follows the viewport. */
|
||||||
|
FitDay,
|
||||||
|
|
||||||
|
/** Denser than the default, still a fixed height. */
|
||||||
|
Compact,
|
||||||
|
|
||||||
|
/** The historical 56dp scale. */
|
||||||
|
Regular,
|
||||||
|
|
||||||
|
/** Roomier blocks, more scrolling. */
|
||||||
|
Comfortable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scale the timelines draw at, from the `timelineScale` preference. */
|
||||||
|
val LocalTimelineScale = staticCompositionLocalOf { TimelineScale.Regular }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hour height for this scale. [viewportHeight] is the visible height of the
|
||||||
|
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
|
||||||
|
*
|
||||||
|
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
|
||||||
|
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
|
||||||
|
* blocks absurdly. On a screen too short for the whole day the clamp wins and
|
||||||
|
* the timeline still scrolls a little — honest, rather than unreadable.
|
||||||
|
*/
|
||||||
|
fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
||||||
|
TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX)
|
||||||
|
TimelineScale.Compact -> 40.dp
|
||||||
|
TimelineScale.Regular -> 56.dp
|
||||||
|
TimelineScale.Comfortable -> 80.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
||||||
|
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
||||||
|
* height — a fixed 24dp would swallow half an hour once zoomed out and make
|
||||||
|
* short events overlap their neighbours.
|
||||||
|
*/
|
||||||
|
const val MIN_EVENT_FRACTION = 26f / 60f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrowest an event block may be and still wrap its title over several lines.
|
||||||
|
*
|
||||||
|
* Wrapping is driven by the block's height, so a tall block on a lane-split
|
||||||
|
* column would otherwise stack two or three characters per line — "Da/ily",
|
||||||
|
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
|
||||||
|
* clears this on any phone; a split one never does, while the day view's much
|
||||||
|
* wider columns keep wrapping even several lanes deep.
|
||||||
|
*/
|
||||||
|
val MIN_TITLE_WRAP_WIDTH = 36.dp
|
||||||
|
|
||||||
|
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
|
val FIT_DAY_MIN = 24.dp
|
||||||
|
|
||||||
|
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
|
val FIT_DAY_MAX = 96.dp
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val TimelineScale.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
TimelineScale.FitDay -> R.string.timeline_scale_fit_day
|
||||||
|
TimelineScale.Compact -> R.string.timeline_scale_compact
|
||||||
|
TimelineScale.Regular -> R.string.timeline_scale_regular
|
||||||
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val TimelineScale.descriptionRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary
|
||||||
|
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
|
||||||
|
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
||||||
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
||||||
|
}
|
||||||
@@ -87,6 +87,10 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
@@ -100,13 +104,11 @@ import kotlin.time.Clock
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
|
||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||||
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
@@ -264,7 +266,6 @@ private fun DayContent(
|
|||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -275,11 +276,9 @@ private fun DayContent(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
val maxV = scrollState.maxValue
|
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||||
val target = with(density) {
|
// centring the range centres midday at whatever hour height is in force.
|
||||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
}.coerceIn(0, maxV)
|
|
||||||
scrollState.scrollTo(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
@@ -485,12 +484,17 @@ private fun Timeline(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val scale = LocalTimelineScale.current
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||||
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
|
// bar, date header and all-day strip.
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val hourHeight = scale.hourHeight(maxHeight)
|
||||||
|
val totalHeight = hourHeight * 24
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
@@ -509,7 +513,7 @@ private fun Timeline(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(HOUR_HEIGHT),
|
.height(hourHeight),
|
||||||
) {
|
) {
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
@@ -537,6 +541,7 @@ private fun Timeline(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
date = state.date,
|
date = state.date,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
hourHeight = hourHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -554,11 +559,12 @@ private fun DayColumnCard(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
Card(
|
Card(
|
||||||
@@ -587,14 +593,16 @@ private fun DayColumnCard(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
|
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val laneWidth = colWidth / block.laneCount
|
||||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
height = height,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
@@ -605,7 +613,7 @@ private fun DayColumnCard(
|
|||||||
}
|
}
|
||||||
// Current-time line, on top of the events, only on today's column.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,6 +623,7 @@ private fun DayColumnCard(
|
|||||||
private fun EventBlock(
|
private fun EventBlock(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
height: Dp,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -623,7 +632,20 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
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 density = LocalDensity.current
|
||||||
|
val titleLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
val timeLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
||||||
|
// that cannot afford both lines spends its space on the title, and one too
|
||||||
|
// short even for that drops the title rather than serving a sliced one.
|
||||||
|
val available = height - 4.dp
|
||||||
|
val showTime = block.endMin - block.startMin >= 45 &&
|
||||||
|
available >= titleLineHeight + timeLineHeight
|
||||||
|
val showTitle = available >= titleLineHeight
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
@@ -634,20 +656,22 @@ private fun EventBlock(
|
|||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
if (showTitle) {
|
||||||
text = title,
|
Text(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
text = title,
|
||||||
maxLines = if (showTime) 1 else 2,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = if (showTime) 1 else 2,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.6f),
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,17 +680,22 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayLoading() {
|
private fun DayLoading() {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val scale = LocalTimelineScale.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||||
Box(
|
// doesn't resize the moment the real day arrives.
|
||||||
modifier = Modifier
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
.weight(1f)
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
.height(totalHeight)
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
.padding(horizontal = 2.dp)
|
Box(
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
modifier = Modifier
|
||||||
)
|
.weight(1f)
|
||||||
|
.height(totalHeight)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ 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.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
@@ -386,7 +387,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 = eventFill(instance.color, dark, LocalSoftenColors.current)
|
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
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
|
||||||
@@ -563,7 +564,7 @@ private fun EventEditContent(
|
|||||||
// 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 soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
|
||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ 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.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
@@ -1473,7 +1474,7 @@ private fun SplitDots(
|
|||||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.background(eventAccent(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden.isNotEmpty()) {
|
if (hidden.isNotEmpty()) {
|
||||||
@@ -2098,7 +2099,7 @@ private fun OverflowDots(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(argb, dark, soften), CircleShape),
|
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val extra = total - dots.size
|
val extra = total - dots.size
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.InlineTextField
|
|||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -208,7 +209,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(eventFill(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ data class SettingsUiState(
|
|||||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||||
|
/** How tall an hour is drawn in the week and day timelines (#56). */
|
||||||
|
val timelineScale: TimelineScale = TimelineScale.Regular,
|
||||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
@@ -164,8 +165,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.drawerViewOrder,
|
prefs.drawerViewOrder,
|
||||||
prefs.monthViewStyle,
|
prefs.monthViewStyle,
|
||||||
prefs.widgetSize,
|
prefs.widgetSize,
|
||||||
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
prefs.timelineScale,
|
||||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
||||||
|
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -189,6 +191,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
widgetSize = misc.viewCustomization.widgetSize,
|
widgetSize = misc.viewCustomization.widgetSize,
|
||||||
|
timelineScale = misc.viewCustomization.timelineScale,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -295,6 +298,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
val monthViewStyle: MonthViewStyle,
|
val monthViewStyle: MonthViewStyle,
|
||||||
val widgetSize: WidgetSize,
|
val widgetSize: WidgetSize,
|
||||||
|
val timelineScale: TimelineScale,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
@@ -581,6 +585,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTimelineScale(scale: TimelineScale) {
|
||||||
|
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,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.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
@@ -68,6 +70,7 @@ internal fun ViewsScreen(
|
|||||||
var showTimeFormat by remember { mutableStateOf(false) }
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
|
var showTimelineScale by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
@@ -144,10 +147,16 @@ internal fun ViewsScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
|
summary = stringResource(state.timelineScale.labelRes),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showTimelineScale = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_hour_lines),
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
position = Position.Alone,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.showHourLines,
|
checked = state.showHourLines,
|
||||||
@@ -321,6 +330,19 @@ internal fun ViewsScreen(
|
|||||||
onDismiss = { showPastEvents = false },
|
onDismiss = { showPastEvents = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showTimelineScale) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = TimelineScale.entries,
|
||||||
|
selected = state.timelineScale,
|
||||||
|
label = { stringResource(it.labelRes) },
|
||||||
|
summary = { stringResource(it.descriptionRes) },
|
||||||
|
onSelect = viewModel::setTimelineScale,
|
||||||
|
onDismiss = { showTimelineScale = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showAgendaScreenRange) {
|
if (showAgendaScreenRange) {
|
||||||
AgendaRangePicker(
|
AgendaRangePicker(
|
||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
@@ -113,15 +118,12 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
|
||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||||
* (the app bar's 4dp inset + 24dp half icon button). */
|
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
@@ -294,7 +296,6 @@ private fun WeekContent(
|
|||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -306,11 +307,9 @@ private fun WeekContent(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
val maxV = scrollState.maxValue
|
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||||
val target = with(density) {
|
// centring the range centres midday at whatever hour height is in force.
|
||||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
}.coerceIn(0, maxV)
|
|
||||||
scrollState.scrollTo(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
@@ -620,12 +619,17 @@ private fun Timeline(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val scale = LocalTimelineScale.current
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||||
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
|
// bar, day header and all-day strip.
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val hourHeight = scale.hourHeight(maxHeight)
|
||||||
|
val totalHeight = hourHeight * 24
|
||||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
@@ -645,7 +649,7 @@ private fun Timeline(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(HOUR_HEIGHT),
|
.height(hourHeight),
|
||||||
) {
|
) {
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
@@ -680,6 +684,7 @@ private fun Timeline(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
date = day,
|
date = day,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
hourHeight = hourHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -699,11 +704,12 @@ private fun DayColumnCard(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
Card(
|
Card(
|
||||||
@@ -731,15 +737,17 @@ private fun DayColumnCard(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
|
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val laneWidth = colWidth / block.laneCount
|
||||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
height = height,
|
height = height,
|
||||||
|
width = laneWidth,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
@@ -750,7 +758,7 @@ private fun DayColumnCard(
|
|||||||
}
|
}
|
||||||
// Current-time line, on top of the events, only on today's column.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,6 +769,7 @@ private fun EventBlock(
|
|||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
|
width: Dp,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -769,10 +778,6 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
|
||||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
|
||||||
// whole block, mirroring Google Calendar.
|
|
||||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val titleLineHeight = with(density) {
|
val titleLineHeight = with(density) {
|
||||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
@@ -780,11 +785,30 @@ private fun EventBlock(
|
|||||||
val timeLineHeight = with(density) {
|
val timeLineHeight = with(density) {
|
||||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
}
|
}
|
||||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
// What's left for text once the 2.dp top/bottom padding is paid for.
|
||||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
val available = height - 4.dp
|
||||||
// single character on slim, overlapping blocks.
|
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||||
|
// afford both lines spends its space on the title.
|
||||||
|
val showTime = block.endMin - block.startMin >= 45 &&
|
||||||
|
block.laneCount == 1 &&
|
||||||
|
available >= titleLineHeight + timeLineHeight
|
||||||
|
// A short block drops the title rather than serving a horizontally sliced
|
||||||
|
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||||
|
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||||
|
// the semantics description carries the full title either way.
|
||||||
|
val showTitle = available >= titleLineHeight
|
||||||
|
// Wrap the title across as many lines as the block can fit — but only once a
|
||||||
|
// line is wide enough to hold more than a syllable. Below that the extra
|
||||||
|
// lines just stack fragments of the word, and one ellipsised line reads
|
||||||
|
// better.
|
||||||
|
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||||
|
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
(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 soften = LocalSoftenColors.current
|
||||||
@@ -797,20 +821,22 @@ private fun EventBlock(
|
|||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
if (showTitle) {
|
||||||
text = title,
|
Text(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
text = title,
|
||||||
maxLines = titleMaxLines,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = titleMaxLines,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.6f),
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,7 +845,7 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekLoading() {
|
private fun WeekLoading() {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val scale = LocalTimelineScale.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header skeleton
|
// Header skeleton
|
||||||
@@ -838,16 +864,21 @@ private fun WeekLoading() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||||
repeat(7) {
|
// columns don't resize the moment the real week arrives.
|
||||||
Box(
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
modifier = Modifier
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
.weight(1f)
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
.height(totalHeight)
|
repeat(7) {
|
||||||
.padding(horizontal = 2.dp)
|
Box(
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
modifier = Modifier
|
||||||
)
|
.weight(1f)
|
||||||
|
.height(totalHeight)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,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.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
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
|
||||||
@@ -382,7 +383,7 @@ private fun EventRow(
|
|||||||
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 = eventFill(event.color, dark, soften).let {
|
val stripeColor = eventAccent(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
|
||||||
|
|||||||
@@ -330,8 +330,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">Harmonise 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_soften_colors_summary">Keep each calendar\'s hue but even out its brightness, so every event stays legible and the colours sit together. 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>
|
||||||
@@ -363,6 +363,16 @@
|
|||||||
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
|
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
|
||||||
<string name="settings_hour_lines">Hour lines</string>
|
<string name="settings_hour_lines">Hour lines</string>
|
||||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||||
|
<string name="settings_timeline_scale">Hour height</string>
|
||||||
|
<string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting.</string>
|
||||||
|
<string name="timeline_scale_fit_day">Fit whole day</string>
|
||||||
|
<string name="timeline_scale_fit_day_summary">All 24 hours on one screen, no scrolling</string>
|
||||||
|
<string name="timeline_scale_compact">Compact</string>
|
||||||
|
<string name="timeline_scale_compact_summary">More hours per screen, smaller blocks</string>
|
||||||
|
<string name="timeline_scale_regular">Regular</string>
|
||||||
|
<string name="timeline_scale_regular_summary">The standard spacing</string>
|
||||||
|
<string name="timeline_scale_comfortable">Comfortable</string>
|
||||||
|
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</string>
|
||||||
<string name="settings_dim_completed">Dim completed events</string>
|
<string name="settings_dim_completed">Dim completed events</string>
|
||||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||||
<string name="settings_past_events">Past events</string>
|
<string name="settings_past_events">Past events</string>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -98,6 +99,16 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.showHourLines.first()).isTrue()
|
assertThat(prefs.showHourLines.first()).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timeline scale defaults to regular and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
// Regular is the historical 56dp scale — an existing install that never
|
||||||
|
// opened the setting must keep the timeline it had (#56).
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Regular)
|
||||||
|
prefs.setTimelineScale(TimelineScale.FitDay)
|
||||||
|
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.FitDay)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ class EventColorPaletteTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
|
fun `neutrals collapse instead of forming a run of look-alikes`() {
|
||||||
// Black and every gray paint as the same pale swatch (the picker pins
|
// Neutrals lose their hue entirely when painted, so they collapse onto
|
||||||
// lightness and floors saturation), so only one survives — no stranded
|
// the one or two greys the poles offer — a dark and a pale — instead of
|
||||||
// run of look-alike "pinks" at the end of the grid (#22).
|
// the old stranded run of look-alike "pinks" at the end of the grid (#22).
|
||||||
val curated = listOf(
|
val curated = listOf(
|
||||||
EventColorOption("black", 0xFF000000.toInt()),
|
EventColorOption("black", 0xFF000000.toInt()),
|
||||||
EventColorOption("gray", 0xFF808080.toInt()),
|
EventColorOption("gray", 0xFF808080.toInt()),
|
||||||
@@ -86,8 +86,11 @@ class EventColorPaletteTest {
|
|||||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||||
).curatedForPicker().map { it.key }
|
).curatedForPicker().map { it.key }
|
||||||
|
|
||||||
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
|
assertThat(curated).doesNotContain("gray") // paints as black's grey
|
||||||
assertThat(curated).containsAtLeast("black", "red", "blue")
|
assertThat(curated).containsAtLeast("black", "red", "blue")
|
||||||
|
// darkgray is light enough to keep a pale character, so the three
|
||||||
|
// neutrals still fold down to at most two swatches, never a run.
|
||||||
|
assertThat(curated.count { it in listOf("black", "gray", "darkgray") }).isAtMost(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.color
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class EventToneTest {
|
||||||
|
|
||||||
|
/** Hues at full chroma, standing in for the provider colours in the wild. */
|
||||||
|
private val hues = (0 until 360 step 15).map { Oklch(0.65f, 0.2f, it.toFloat()).toArgb() }
|
||||||
|
|
||||||
|
private fun relativeLuminance(argb: Int): Double {
|
||||||
|
fun channel(shift: Int): Double {
|
||||||
|
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||||
|
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
|
||||||
|
}
|
||||||
|
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contrast(a: Int, b: Int): Double {
|
||||||
|
val hi = maxOf(relativeLuminance(a), relativeLuminance(b))
|
||||||
|
val lo = minOf(relativeLuminance(a), relativeLuminance(b))
|
||||||
|
return (hi + 0.05) / (lo + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The other ink, for asserting the chosen one is the better of the two. */
|
||||||
|
private fun flip(ink: Int) =
|
||||||
|
if (ink == 0xFFFFFFFF.toInt()) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||||
|
|
||||||
|
/** [ink] over [fill] at [alpha], the way a title actually renders. */
|
||||||
|
private fun composite(ink: Int, fill: Int, alpha: Double): Int {
|
||||||
|
fun mix(shift: Int): Int {
|
||||||
|
val i = (ink shr shift) and 0xFF
|
||||||
|
val f = (fill shr shift) and 0xFF
|
||||||
|
return (alpha * i + (1 - alpha) * f).toInt().coerceIn(0, 255)
|
||||||
|
}
|
||||||
|
return (0xFF shl 24) or (mix(16) shl 16) or (mix(8) shl 8) or mix(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hues of one lightness all get the same ink`() {
|
||||||
|
// The bug this replaced: at a pinned HSV value the hues straddled the ink
|
||||||
|
// crossover, so red took white while orange beside it took black. Ink may
|
||||||
|
// still differ by *pole*, but never by hue within a pole.
|
||||||
|
for (dark in listOf(false, true)) {
|
||||||
|
val inks = hues.map { eventTone(it, dark, harmonise = true).onContainer }.toSet()
|
||||||
|
assertThat(inks).containsExactly(0xFFFFFFFF.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `naturally light colours keep their character instead of turning muddy`() {
|
||||||
|
// Forcing every hue deep made a yellow into olive and a cream into brown.
|
||||||
|
// Pale sources stay pale — and then take dark ink.
|
||||||
|
for (pale in listOf(0xFFF6BF26, 0xFFFFF8DC, 0xFFF0E68C, 0xFFFFFFFF)) {
|
||||||
|
val tone = eventTone(pale.toInt(), dark = false, harmonise = true)
|
||||||
|
assertThat(oklchOf(tone.container).lightness).isGreaterThan(0.8f)
|
||||||
|
assertThat(tone.onContainer).isEqualTo(0xFF000000.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `warm mid colours still go deep, so orange reads as orange and not as cream`() {
|
||||||
|
// The threshold has to sit above orange: a burnt orange still says
|
||||||
|
// "orange", which is why it belongs on the deep pole with white ink.
|
||||||
|
// Note the ceiling this implies: a *bright* orange (#FF8C00, #FFA500) is
|
||||||
|
// genuinely a light colour and takes the light pole instead — forcing it
|
||||||
|
// deep is exactly what produced brown.
|
||||||
|
for (warm in listOf(0xFFF4511E, 0xFFE67C73, 0xFFD50000, 0xFFE65100)) {
|
||||||
|
val tone = eventTone(warm.toInt(), dark = false, harmonise = true)
|
||||||
|
assertThat(oklchOf(tone.container).lightness).isLessThan(0.6f)
|
||||||
|
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title ink clears WCAG AA on every hue`() {
|
||||||
|
for (dark in listOf(false, true)) {
|
||||||
|
for (hue in hues) {
|
||||||
|
val tone = eventTone(hue, dark, harmonise = true)
|
||||||
|
val ink = composite(tone.onContainer, tone.container, 0.85)
|
||||||
|
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
|
||||||
|
assertThat(contrast(tone.container, ink))
|
||||||
|
.isGreaterThan(contrast(tone.container, composite(flip(tone.onContainer), tone.container, 0.85)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the secondary line also clears WCAG AA on every hue`() {
|
||||||
|
for (dark in listOf(false, true)) {
|
||||||
|
for (hue in hues) {
|
||||||
|
val tone = eventTone(hue, dark, harmonise = true)
|
||||||
|
val ink = composite(tone.onContainer, tone.container, 0.8)
|
||||||
|
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an accent stays visible against its own theme's surface`() {
|
||||||
|
// Accents carry no text, so the bar is WCAG's 3:1 for non-text contrast.
|
||||||
|
val lightSurface = 0xFFFEF7FF.toInt()
|
||||||
|
val darkSurface = 0xFF141218.toInt()
|
||||||
|
for ((dark, surface) in listOf(false to lightSurface, true to darkSurface)) {
|
||||||
|
for (hue in hues) {
|
||||||
|
val accent = eventTone(hue, dark, harmonise = true).accent
|
||||||
|
assertThat(contrast(accent, surface)).isGreaterThan(3.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `container and accent keep the same hue, so one calendar reads as one colour`() {
|
||||||
|
for (hue in hues) {
|
||||||
|
val tone = eventTone(hue, dark = false, harmonise = true)
|
||||||
|
assertThat(oklchOf(tone.accent).hue).isWithin(2f).of(oklchOf(tone.container).hue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `orange no longer gets dark ink`() {
|
||||||
|
// The reported case: a plain orange calendar used to fall just above the
|
||||||
|
// crossover and take black text while its neighbours took white.
|
||||||
|
val tone = eventTone(0xFFF4511E.toInt(), dark = false, harmonise = true)
|
||||||
|
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `different hues stay distinguishable`() {
|
||||||
|
val containers = hues.map { eventTone(it, dark = false, harmonise = true).container }
|
||||||
|
assertThat(containers.toSet()).hasSize(hues.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a grey source stays grey rather than gaining an invented hue`() {
|
||||||
|
for (grey in listOf(0xFF000000, 0xFF808080, 0xFFFFFFFF, 0xFF9E9E9E)) {
|
||||||
|
val tone = eventTone(grey.toInt(), dark = false, harmonise = true)
|
||||||
|
assertThat(oklchOf(tone.container).chroma).isLessThan(0.01f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `raw colours are painted verbatim when harmonising is off`() {
|
||||||
|
val raw = 0xFFF4511E.toInt()
|
||||||
|
val tone = eventTone(raw, dark = false, harmonise = false)
|
||||||
|
assertThat(tone.container).isEqualTo(raw)
|
||||||
|
assertThat(tone.accent).isEqualTo(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `raw mode still picks a readable ink per colour`() {
|
||||||
|
// Nothing constrains what a provider sends, so the ink cannot be constant
|
||||||
|
// on this path the way it is for harmonised containers.
|
||||||
|
assertThat(eventTone(0xFF101010.toInt(), dark = false, harmonise = false).onContainer)
|
||||||
|
.isEqualTo(0xFFFFFFFF.toInt())
|
||||||
|
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
|
||||||
|
.isEqualTo(0xFF000000.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.color
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class OklchTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matches Oklab's published anchors`() {
|
||||||
|
// Guards the conversion matrices against a transcription slip: these are
|
||||||
|
// Ottosson's own reference values for the space.
|
||||||
|
assertThat(oklchOf(0xFFFFFFFF.toInt()).lightness).isWithin(0.001f).of(1f)
|
||||||
|
assertThat(oklchOf(0xFF000000.toInt()).lightness).isWithin(0.001f).of(0f)
|
||||||
|
assertThat(oklchOf(0xFFFF0000.toInt()).lightness).isWithin(0.001f).of(0.6280f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `white and black have no chroma`() {
|
||||||
|
assertThat(oklchOf(0xFFFFFFFF.toInt()).chroma).isWithin(0.001f).of(0f)
|
||||||
|
assertThat(oklchOf(0xFF000000.toInt()).chroma).isWithin(0.001f).of(0f)
|
||||||
|
assertThat(oklchOf(0xFF808080.toInt()).chroma).isWithin(0.001f).of(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `converting to sRGB and back round-trips`() {
|
||||||
|
for (argb in listOf(0xFFF4511E, 0xFF039BE5, 0xFF0B8043, 0xFF8E24AA, 0xFFFBD75B)) {
|
||||||
|
val original = oklchOf(argb.toInt())
|
||||||
|
val roundTripped = oklchOf(original.toArgb())
|
||||||
|
assertThat(roundTripped.lightness).isWithin(0.01f).of(original.lightness)
|
||||||
|
assertThat(roundTripped.chroma).isWithin(0.01f).of(original.chroma)
|
||||||
|
assertThat(roundTripped.hue).isWithin(1f).of(original.hue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an out-of-gamut request keeps its lightness and gives up chroma`() {
|
||||||
|
// Most of the Oklch cylinder is outside sRGB. Lightness is the axis the
|
||||||
|
// contrast guarantees rest on, so it is the one that must survive.
|
||||||
|
val requested = Oklch(0.45f, 0.35f, 150f)
|
||||||
|
val actual = oklchOf(requested.toArgb())
|
||||||
|
assertThat(actual.lightness).isWithin(0.02f).of(requested.lightness)
|
||||||
|
assertThat(actual.chroma).isLessThan(requested.chroma)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pinning lightness holds luminance far tighter than pinning HSV value did`() {
|
||||||
|
// The whole reason for the space swap. At a pinned HSV value the hues
|
||||||
|
// spread across relative luminance 0.10..0.45; pinned Oklch lightness
|
||||||
|
// must keep them in a narrow band, or one ink cannot serve them all.
|
||||||
|
val luminances = (0 until 360 step 15).map { hue ->
|
||||||
|
val argb = Oklch(0.45f, 0.16f, hue.toFloat()).toArgb()
|
||||||
|
relativeLuminance(argb)
|
||||||
|
}
|
||||||
|
assertThat(luminances.max() / luminances.min()).isLessThan(2.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relativeLuminance(argb: Int): Double {
|
||||||
|
fun channel(shift: Int): Double {
|
||||||
|
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||||
|
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
|
||||||
|
}
|
||||||
|
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class TimelineScaleTest {
|
||||||
|
|
||||||
|
/** A Pixel-7-ish timeline viewport: what the issue reporter is looking at. */
|
||||||
|
private val phoneViewport = 670.dp
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Regular is the timeline's original 56dp constant`() {
|
||||||
|
// The default must not move: an install that never opens the setting has
|
||||||
|
// to keep the week and day views it already had (#56).
|
||||||
|
assertThat(TimelineScale.Regular.hourHeight(phoneViewport)).isEqualTo(56.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the fixed scales ignore the viewport`() {
|
||||||
|
for (scale in listOf(TimelineScale.Compact, TimelineScale.Regular, TimelineScale.Comfortable)) {
|
||||||
|
assertThat(scale.hourHeight(200.dp)).isEqualTo(scale.hourHeight(2000.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the fixed scales get taller in listed order`() {
|
||||||
|
assertThat(TimelineScale.Compact.hourHeight(phoneViewport))
|
||||||
|
.isLessThan(TimelineScale.Regular.hourHeight(phoneViewport))
|
||||||
|
assertThat(TimelineScale.Regular.hourHeight(phoneViewport))
|
||||||
|
.isLessThan(TimelineScale.Comfortable.hourHeight(phoneViewport))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fit-day puts all 24 hours inside a phone viewport`() {
|
||||||
|
// The whole point of the issue: no vertical scrolling to see the day.
|
||||||
|
val h = TimelineScale.FitDay.hourHeight(phoneViewport)
|
||||||
|
assertThat(h * 24).isAtMost(phoneViewport)
|
||||||
|
// …and it uses the space, rather than leaving most of it empty.
|
||||||
|
assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fit-day clamps instead of shrinking hours past legibility`() {
|
||||||
|
// A very short viewport (split screen, tiny device) would otherwise give
|
||||||
|
// hour rows too small for the gutter's 24 labels; the clamp wins and the
|
||||||
|
// timeline keeps a little scroll.
|
||||||
|
assertThat(TimelineScale.FitDay.hourHeight(120.dp)).isEqualTo(FIT_DAY_MIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fit-day clamps instead of stretching hours on a very tall viewport`() {
|
||||||
|
assertThat(TimelineScale.FitDay.hourHeight(4000.dp)).isEqualTo(FIT_DAY_MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the minimum event height matches the old 24dp floor at the default scale`() {
|
||||||
|
// MIN_EVENT_FRACTION replaced a hardcoded 24dp; at Regular it must still
|
||||||
|
// land there, or short events change size for everyone who never touched
|
||||||
|
// the setting.
|
||||||
|
val floor = TimelineScale.Regular.hourHeight(phoneViewport) * MIN_EVENT_FRACTION
|
||||||
|
assertThat(floor.value).isWithin(0.5f).of(24f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the minimum event height stays a fixed share of an hour`() {
|
||||||
|
// A fixed dp floor would swallow ever more of the day as the scale drops;
|
||||||
|
// as a fraction it always means the same duration.
|
||||||
|
for (scale in TimelineScale.entries) {
|
||||||
|
val hour = scale.hourHeight(phoneViewport)
|
||||||
|
assertThat((hour * MIN_EVENT_FRACTION) / hour).isWithin(0.001f).of(MIN_EVENT_FRACTION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user