Compare commits
2 Commits
v2.19.1
...
9287b7b0da
| Author | SHA1 | Date | |
|---|---|---|---|
| 9287b7b0da | |||
| 37995d1fb3 |
@@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
@@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalTimelineScale provides settings.timelineScale,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
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.IMPLEMENTED_VIEWS
|
||||
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.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
@@ -270,6 +271,18 @@ class SettingsPrefs @Inject constructor(
|
||||
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
|
||||
* 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 SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||
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 DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
|
||||
/**
|
||||
* Curates an account's published event palette for the colour picker.
|
||||
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
|
||||
* (#22).
|
||||
*
|
||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||
* space, as before, left near-identical painted swatches and stranded the
|
||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||
* the raw provider value — and it gets that colour from the same [eventTone]
|
||||
* the picker calls, rather than from a copy of its shaping kept in step by hand.
|
||||
* Because a harmonised container pins lightness, the raw palette's lightness
|
||||
* axis is invisible on screen: two raw colours that look different — a navy and
|
||||
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
|
||||
* paints as the same grey. Judging distinctness in raw space, as before, left
|
||||
* 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:
|
||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||
* hue, and all the neutrals together.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||
* colour already kept. Small palettes are already curated by their adapter
|
||||
* and pass through whole.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
|
||||
* swatches — with lightness pinned, a grey source paints as plain grey, so
|
||||
* "has no hue left" is simply zero chroma — and are then thinned to visually
|
||||
* distinct colours: most vivid first, a colour is kept only when at least
|
||||
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
|
||||
* palettes are already curated by their adapter and pass through whole.
|
||||
* 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
|
||||
* 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> {
|
||||
val painted = sortedBy { it.key }
|
||||
.distinctBy { pastelArgb(it.argb) }
|
||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||
.distinctBy { paintedArgb(it.argb) }
|
||||
.map { it to oklchOf(paintedArgb(it.argb)) }
|
||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||
painted
|
||||
} else {
|
||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||
}
|
||||
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.
|
||||
*/
|
||||
private fun orderAroundWheel(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
if (swatches.size < 2) return swatches
|
||||
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;
|
||||
// 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.
|
||||
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) {
|
||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||
if (gap > widestGap) {
|
||||
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
val byVividness = swatches
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
.sortedWith(
|
||||
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
||||
.thenBy { it.first.key },
|
||||
)
|
||||
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* The softening the colour picker paints over every swatch: keep the hue, scale
|
||||
* and clamp saturation into a gentle band, and pin value to a constant so
|
||||
* nothing screams and everything reads on the surface. Value is fixed here so
|
||||
* curation is theme-independent — only hue and saturation distinguish painted
|
||||
* 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
|
||||
/** The colour the picker paints for [argb]; the light theme stands in as the
|
||||
* reference, since a harmonised container differs only in lightness by theme
|
||||
* and curation compares hue and chroma. */
|
||||
private fun paintedArgb(argb: Int): Int =
|
||||
eventTone(argb, dark = false, harmonise = true).container
|
||||
|
||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||
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-
|
||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||
* is dropped rather than shown as pale filler.
|
||||
* Minimum Oklab distance between surviving painted swatches. Painted colours all
|
||||
* share one lightness, so this is really a hue/chroma separation — far enough
|
||||
* apart that two swatches never read as the same colour in the grid.
|
||||
*/
|
||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||
|
||||
/** 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
private const val MIN_DISTANCE = 0.025f
|
||||
|
||||
@@ -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.LocalSoftenColors
|
||||
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.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
@@ -137,7 +138,7 @@ internal fun AgendaEventRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
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.groupByAccount
|
||||
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.LeadingAvatar
|
||||
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(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
|
||||
@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
Icons.Filled.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = eventFill(color, dark, soften),
|
||||
tint = eventAccent(color, dark, soften),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,45 +2,53 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||
import de.jeanlucmakiola.calendula.domain.color.inkFor
|
||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
|
||||
/**
|
||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
||||
* setting; the default `true` keeps the historical look. When off, the raw
|
||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
||||
* and other calendar apps. Widgets live outside this composition and read the
|
||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
||||
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
|
||||
* per role — before display (issue #36). Provided app-wide from the setting; the
|
||||
* default `true` is the app's own look. When off, the raw provider colour is
|
||||
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
|
||||
* apps. Widgets live outside this composition and read the preference directly,
|
||||
* then pass the flag through.
|
||||
*/
|
||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
||||
* pastelize also returns an opaque colour).
|
||||
* Fill for a surface that carries text on it — a week/day block, a month bar, a
|
||||
* widget row, a picker swatch. Pair it with [eventInk].
|
||||
*/
|
||||
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]:
|
||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
||||
* perceptually dark even after softening pins its HSV value, so black text on it
|
||||
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
||||
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
||||
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
||||
* near-black text. [alpha] carries the caller's soft emphasis.
|
||||
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
|
||||
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
|
||||
* pinned to contrast with the surface instead of with ink.
|
||||
*/
|
||||
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
||||
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
||||
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
||||
}
|
||||
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||
Color(eventTone(rawArgb, dark, soften).accent)
|
||||
|
||||
/**
|
||||
* Relative-luminance crossover where white text starts to out-contrast black.
|
||||
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
||||
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
||||
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
|
||||
*
|
||||
* 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.calendula.ui.common.LocalUse24HourFormat
|
||||
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.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
@@ -100,13 +104,11 @@ import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** 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
|
||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||
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_VERTICAL_PADDING = 6.dp
|
||||
|
||||
@@ -264,7 +266,6 @@ private fun DayContent(
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -275,11 +276,9 @@ private fun DayContent(
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
val maxV = scrollState.maxValue
|
||||
val target = with(density) {
|
||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||
}.coerceIn(0, maxV)
|
||||
scrollState.scrollTo(target)
|
||||
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||
// centring the range centres midday at whatever hour height is in force.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
@@ -485,12 +484,17 @@ private fun Timeline(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
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
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
@@ -509,7 +513,7 @@ private fun Timeline(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(HOUR_HEIGHT),
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
@@ -537,6 +541,7 @@ private fun Timeline(
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
modifier = Modifier
|
||||
@@ -554,11 +559,12 @@ private fun DayColumnCard(
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
Card(
|
||||
@@ -587,14 +593,16 @@ private fun DayColumnCard(
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.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.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -615,6 +623,7 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -623,7 +632,20 @@ private fun EventBlock(
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, 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 fill = eventFill(block.event.color, dark, soften)
|
||||
Box(
|
||||
@@ -634,20 +656,22 @@ private fun EventBlock(
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -656,17 +680,22 @@ private fun EventBlock(
|
||||
|
||||
@Composable
|
||||
private fun DayLoading() {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scale = LocalTimelineScale.current
|
||||
val scrollState = rememberScrollState()
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||
// doesn't resize the moment the real day arrives.
|
||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
Box(
|
||||
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.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -386,7 +387,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
val locale = currentDetailLocale()
|
||||
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||
|
||||
Column(
|
||||
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.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
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
|
||||
// bar under the title takes the target calendar's colour.
|
||||
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
|
||||
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.LocalDimCutoff
|
||||
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.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
@@ -1473,7 +1474,7 @@ private fun SplitDots(
|
||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||
.size(SPLIT_DOT_SIZE)
|
||||
.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()) {
|
||||
@@ -2098,7 +2099,7 @@ private fun OverflowDots(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.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
|
||||
|
||||
@@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
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.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -208,7 +209,7 @@ private fun SearchResultRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
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.IMPLEMENTED_VIEWS
|
||||
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.widget.WidgetSize
|
||||
|
||||
@@ -57,6 +58,8 @@ data class SettingsUiState(
|
||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||
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. */
|
||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** 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.common.CalendarView
|
||||
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.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
@@ -164,8 +165,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.drawerViewOrder,
|
||||
prefs.monthViewStyle,
|
||||
prefs.widgetSize,
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
||||
prefs.timelineScale,
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
@@ -189,6 +191,7 @@ class SettingsViewModel @Inject constructor(
|
||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||
widgetSize = misc.viewCustomization.widgetSize,
|
||||
timelineScale = misc.viewCustomization.timelineScale,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -295,6 +298,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val drawerOrder: List<CalendarView>,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val widgetSize: WidgetSize,
|
||||
val timelineScale: TimelineScale,
|
||||
)
|
||||
|
||||
/** 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) }
|
||||
}
|
||||
|
||||
fun setTimelineScale(scale: TimelineScale) {
|
||||
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||
}
|
||||
|
||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
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.PickerDescription
|
||||
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.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
@@ -68,6 +70,7 @@ internal fun ViewsScreen(
|
||||
var showTimeFormat by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showTimelineScale by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
@@ -144,10 +147,16 @@ internal fun ViewsScreen(
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
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(
|
||||
title = stringResource(R.string.settings_hour_lines),
|
||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||
position = Position.Alone,
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
@@ -321,6 +330,19 @@ internal fun ViewsScreen(
|
||||
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) {
|
||||
AgendaRangePicker(
|
||||
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.calendula.ui.common.LocalUse24HourFormat
|
||||
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.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
@@ -113,15 +118,12 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** 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
|
||||
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||
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_VERTICAL_PADDING = 6.dp
|
||||
|
||||
@@ -294,7 +296,6 @@ private fun WeekContent(
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -306,11 +307,9 @@ private fun WeekContent(
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
val maxV = scrollState.maxValue
|
||||
val target = with(density) {
|
||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||
}.coerceIn(0, maxV)
|
||||
scrollState.scrollTo(target)
|
||||
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||
// centring the range centres midday at whatever hour height is in force.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
@@ -620,12 +619,17 @@ private fun Timeline(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
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
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
@@ -645,7 +649,7 @@ private fun Timeline(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(HOUR_HEIGHT),
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
@@ -680,6 +684,7 @@ private fun Timeline(
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
modifier = Modifier
|
||||
@@ -699,11 +704,12 @@ private fun DayColumnCard(
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
Card(
|
||||
@@ -731,15 +737,17 @@ private fun DayColumnCard(
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
width = laneWidth,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.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.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,6 +769,7 @@ private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -769,10 +778,6 @@ private fun EventBlock(
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, 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 titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
@@ -780,11 +785,30 @@ private fun EventBlock(
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
||||
// single character on slim, overlapping blocks.
|
||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
// What's left for text once the 2.dp top/bottom padding is paid for.
|
||||
val available = height - 4.dp
|
||||
// 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 — 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 dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
@@ -797,20 +821,22 @@ private fun EventBlock(
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -819,7 +845,7 @@ private fun EventBlock(
|
||||
|
||||
@Composable
|
||||
private fun WeekLoading() {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scale = LocalTimelineScale.current
|
||||
val scrollState = rememberScrollState()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header skeleton
|
||||
@@ -838,16 +864,21 @@ private fun WeekLoading() {
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||
// columns don't resize the moment the real week arrives.
|
||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
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.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -382,7 +383,7 @@ private fun EventRow(
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
val stripeColor = eventFill(event.color, dark, soften).let {
|
||||
val stripeColor = eventAccent(event.color, dark, soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
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_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_soften_colors">Soften calendar colours</string>
|
||||
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
|
||||
<string name="settings_soften_colors">Harmonise calendar colours</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_body">Body font</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_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_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_summary">Fade events that have already ended in month and week view</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.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -98,6 +99,16 @@ class SettingsPrefsTest {
|
||||
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
|
||||
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
|
||||
@@ -74,10 +74,10 @@ class EventColorPaletteTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
|
||||
// Black and every gray paint as the same pale swatch (the picker pins
|
||||
// lightness and floors saturation), so only one survives — no stranded
|
||||
// run of look-alike "pinks" at the end of the grid (#22).
|
||||
fun `neutrals collapse instead of forming a run of look-alikes`() {
|
||||
// Neutrals lose their hue entirely when painted, so they collapse onto
|
||||
// the one or two greys the poles offer — a dark and a pale — instead of
|
||||
// the old stranded run of look-alike "pinks" at the end of the grid (#22).
|
||||
val curated = listOf(
|
||||
EventColorOption("black", 0xFF000000.toInt()),
|
||||
EventColorOption("gray", 0xFF808080.toInt()),
|
||||
@@ -86,8 +86,11 @@ class EventColorPaletteTest {
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).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")
|
||||
// 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
|
||||
|
||||
@@ -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