Compare commits
4 Commits
renovate/l
...
06ce08e12e
| Author | SHA1 | Date | |
|---|---|---|---|
| 06ce08e12e | |||
| 84922dc088 | |||
|
|
327776eaca | ||
|
|
94d989537d |
@@ -264,10 +264,9 @@ jobs:
|
||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||
|
||||
# Per-version "What's New": ensure this version's changelog exists in the
|
||||
# fastlane tree. The committed hand-written summary (kept under Play's
|
||||
# 500-char cap) is used as-is; only if it is missing does the script fall
|
||||
# back to CHANGELOG.md, so the self-hosted repo never depends on the
|
||||
# commit having happened. The transform below then carries it across.
|
||||
# fastlane tree (committed at release-cut time for the official repo; this
|
||||
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
||||
# the commit having happened). The transform below then carries it across.
|
||||
- name: Ensure this version's changelog is in the fastlane tree
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -7,17 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.18.1] — 2026-08-06
|
||||
|
||||
### Fixed
|
||||
- Tapping an empty slot in week or day view creates the event at the hour you
|
||||
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
|
||||
— or changing **Hour height** in Settings — left the tap still being measured
|
||||
against the old spacing, so the new event landed at some other hour entirely
|
||||
([#148]).
|
||||
|
||||
## [2.18.0] — 2026-07-31
|
||||
|
||||
### Added
|
||||
- A new event no longer always lasts an hour. **Settings → New event form →
|
||||
Default duration** sets how long one opens, and each calendar may keep its own
|
||||
@@ -27,18 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
setting an end time by hand keeps it. An event another app hands over with only
|
||||
a start gets the default too; one that names its own end — an `.ics` file, a
|
||||
duplicate — keeps that length. All-day events are unaffected ([#54]).
|
||||
- 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]).
|
||||
- Week and day view can be **pinched** with two fingers to set the hour height
|
||||
directly, anywhere between and beyond the named steps. The time under your
|
||||
fingers stays put as it zooms, so you keep your place in the day. A pinched
|
||||
height is remembered and appears as **Custom** in the Hour height setting, so
|
||||
tapping a named step there takes you back to it ([#56]).
|
||||
|
||||
### Changed
|
||||
- The date in the top bar is now the way to jump: tapping the month, week or day
|
||||
@@ -50,36 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
date surfaces and every other calendar app. The status bar draws that icon as
|
||||
a plain silhouette, so its shape is the only thing that could tell them apart
|
||||
([#83]).
|
||||
- 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]).
|
||||
|
||||
### Fixed
|
||||
- The back gesture closes the sidebar instead of the app. With the drawer open,
|
||||
swiping back left Calendula altogether rather than putting the sidebar away
|
||||
([#114]).
|
||||
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
|
||||
edge of the rows under them, and the view, jump-to-date, Settings and calendar
|
||||
rows sit on one vertical axis instead of each icon finding its own — the same
|
||||
alignment the Settings screens use ([#114]).
|
||||
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
|
||||
Setting the app dark while the system stayed light — or the other way round —
|
||||
left the clock and battery drawn for the system's theme, so they could sit
|
||||
near-invisible against the app's own bar ([#70]).
|
||||
|
||||
## [2.17.1] — 2026-07-30
|
||||
|
||||
@@ -1330,6 +1277,3 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
|
||||
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
||||
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
||||
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21801
|
||||
versionName = "2.18.1"
|
||||
versionCode = 21701
|
||||
versionName = "2.17.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -14,7 +12,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -32,8 +29,6 @@ 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.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
@@ -62,11 +57,6 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
// Which of light/dark the system bars are drawn for. The styles installed
|
||||
// in onCreate read this field live, so androidx's config-change replay
|
||||
// picks up the in-app override instead of the night resource qualifier.
|
||||
private var systemBarsDark = false
|
||||
|
||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||
// tap into the running activity; CalendarHost consumes and clears it.
|
||||
@@ -110,9 +100,7 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
applyEdgeToEdge()
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
@@ -129,14 +117,6 @@ class MainActivity : AppCompatActivity() {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
// onCreate can only see the night resource qualifier, not the in-app
|
||||
// override — re-apply from the resolved theme so the bar icons follow
|
||||
// the app's light/dark choice.
|
||||
DisposableEffect(darkTheme) {
|
||||
systemBarsDark = darkTheme
|
||||
applyEdgeToEdge()
|
||||
onDispose {}
|
||||
}
|
||||
// The app-wide clock convention: the time-format preference resolved
|
||||
// against the device's 24-hour system setting, provided once here so
|
||||
// every time label reads it via LocalUse24HourFormat.
|
||||
@@ -149,13 +129,6 @@ class MainActivity : AppCompatActivity() {
|
||||
// re-import stamp AppFontSettings carries, so replacing the file
|
||||
// behind an active "custom" token still refreshes — changes;
|
||||
// "system for both" returns the default scale untouched.
|
||||
// The timeline scale plus the pinch in flight over it (#56). Held
|
||||
// here, above the calendar views, so a zoom survives paging between
|
||||
// weeks and switching between the week and day view.
|
||||
val timelineZoom = rememberTimelineZoom(
|
||||
stored = settings.timelineScale,
|
||||
onPersist = settingsViewModel::setTimelineScale,
|
||||
)
|
||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||
val typography = remember(fonts, context) {
|
||||
calendulaTypography(
|
||||
@@ -172,7 +145,6 @@ class MainActivity : AppCompatActivity() {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalTimelineZoom provides timelineZoom,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
@@ -214,19 +186,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
|
||||
* Both scrims are transparent because API 29+ enforces its own contrast and
|
||||
* ignores them anyway.
|
||||
*/
|
||||
private fun applyEdgeToEdge() {
|
||||
val transparent = android.graphics.Color.TRANSPARENT
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||
|
||||
@@ -21,9 +21,6 @@ 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.common.parseTimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
@@ -273,19 +270,6 @@ 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. Holds either a preset
|
||||
* or the height a pinch on the timeline settled at.
|
||||
*/
|
||||
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
|
||||
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setTimelineScale(scale: TimelineScale) {
|
||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 "+"
|
||||
@@ -864,7 +848,6 @@ 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,8 +1,12 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
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
|
||||
|
||||
/**
|
||||
* Curates an account's published event palette for the colour picker.
|
||||
@@ -14,25 +18,24 @@ import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
* (#22).
|
||||
*
|
||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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 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.
|
||||
* 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.
|
||||
* 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.
|
||||
@@ -42,12 +45,12 @@ import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
*/
|
||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
val painted = sortedBy { it.key }
|
||||
.distinctBy { paintedArgb(it.argb) }
|
||||
.map { it to oklchOf(paintedArgb(it.argb)) }
|
||||
.distinctBy { pastelArgb(it.argb) }
|
||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||
painted
|
||||
} else {
|
||||
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||
}
|
||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||
}
|
||||
@@ -58,17 +61,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||
*/
|
||||
private fun orderAroundWheel(
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
if (swatches.size < 2) return swatches
|
||||
val byHue = swatches.sortedWith(
|
||||
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
|
||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.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 = 360f - byHue.last().second.hue + byHue.first().second.hue
|
||||
var widestGap = 360.0 - 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) {
|
||||
@@ -81,32 +84,102 @@ private fun orderAroundWheel(
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
val byVividness = swatches
|
||||
.sortedWith(
|
||||
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
||||
.thenBy { it.first.key },
|
||||
)
|
||||
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
for (candidate in byVividness) {
|
||||
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/** 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
|
||||
/**
|
||||
* 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
|
||||
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
private const val MIN_DISTANCE = 0.025f
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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
|
||||
@@ -1,119 +0,0 @@
|
||||
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,7 +31,6 @@ 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
|
||||
@@ -138,7 +137,7 @@ internal fun AgendaEventRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
|
||||
@@ -112,7 +112,6 @@ fun AgendaScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
|
||||
@@ -88,7 +88,6 @@ 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
|
||||
@@ -443,7 +442,7 @@ private fun CalendarEditor(
|
||||
)
|
||||
}
|
||||
}
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(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 = eventAccent(color, dark, soften),
|
||||
tint = eventFill(color, dark, soften),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -20,7 +19,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
@@ -29,23 +27,19 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
@@ -56,8 +50,7 @@ import kotlinx.datetime.LocalDate
|
||||
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||
* The host screen owns the drawer state; the sheet reads it only to dismiss
|
||||
* itself on back.
|
||||
* The host screen owns the drawer state.
|
||||
*
|
||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||
@@ -66,17 +59,12 @@ import kotlinx.datetime.LocalDate
|
||||
fun CalendarDrawer(
|
||||
currentView: CalendarView,
|
||||
currentDate: LocalDate,
|
||||
drawerState: DrawerState,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Registered in the sheet so it takes precedence over the host's back handler.
|
||||
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
|
||||
|
||||
ModalDrawerSheet {
|
||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||
@@ -95,7 +83,7 @@ fun CalendarDrawer(
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
leading = { DrawerLeadingIcon(view.icon) },
|
||||
leading = { Icon(view.icon, contentDescription = null) },
|
||||
onClick = { onSelectView(view) },
|
||||
)
|
||||
}
|
||||
@@ -105,7 +93,7 @@ fun CalendarDrawer(
|
||||
title = stringResource(R.string.drawer_jump_to_date),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||
onClick = { showDatePicker = true },
|
||||
)
|
||||
|
||||
@@ -119,7 +107,7 @@ fun CalendarDrawer(
|
||||
title = stringResource(R.string.month_action_settings),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
|
||||
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
onClick = onSettings,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -138,27 +126,13 @@ fun CalendarDrawer(
|
||||
}
|
||||
}
|
||||
|
||||
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
|
||||
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
|
||||
@Composable
|
||||
private fun DrawerLeadingIcon(icon: ImageVector) {
|
||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, contentDescription = null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Branded header: the app-icon chip beside the app name. */
|
||||
@Composable
|
||||
private fun DrawerHeader() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 24.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
@@ -190,11 +164,6 @@ private fun DrawerSectionHeader(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,53 +2,45 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||
import de.jeanlucmakiola.calendula.domain.color.inkFor
|
||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
||||
* setting; the default `true` keeps the historical look. When off, the raw
|
||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
||||
* and other calendar apps. Widgets live outside this composition and read the
|
||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
||||
*/
|
||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* 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].
|
||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
||||
* pastelize also returns an opaque colour).
|
||||
*/
|
||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||
Color(eventTone(rawArgb, dark, soften).container)
|
||||
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
||||
* perceptually dark even after softening pins its HSV value, so black text on it
|
||||
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
||||
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
||||
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
||||
* near-black text. [alpha] carries the caller's soft emphasis.
|
||||
*/
|
||||
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||
Color(eventTone(rawArgb, dark, soften).accent)
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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`.
|
||||
*/
|
||||
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)
|
||||
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.
|
||||
*
|
||||
* [Custom] is what a pinch on the timeline leaves behind. The presets are the
|
||||
* settings-screen vocabulary; a pinch is a direct manipulation and should be
|
||||
* able to land anywhere between them, so it gets its own case rather than
|
||||
* snapping to the nearest named step.
|
||||
*/
|
||||
sealed interface TimelineScale {
|
||||
|
||||
/** Whole day in one screen: the hour height follows the viewport. */
|
||||
data object FitDay : TimelineScale
|
||||
|
||||
/** Denser than the default, still a fixed height. */
|
||||
data object Compact : TimelineScale
|
||||
|
||||
/** The historical 56dp scale. */
|
||||
data object Regular : TimelineScale
|
||||
|
||||
/** Roomier blocks, more scrolling. */
|
||||
data object Comfortable : TimelineScale
|
||||
|
||||
/** A height the user pinched to. Build it through [custom], which clamps. */
|
||||
data class Custom(val hourHeight: Dp) : TimelineScale
|
||||
|
||||
companion object {
|
||||
/** The named steps the settings picker offers, coarse to roomy. */
|
||||
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
||||
|
||||
/**
|
||||
* A pinched hour height. The floor a pinch actually stops at depends on
|
||||
* the viewport and is applied when the height is resolved (see
|
||||
* [hourHeight]); this only holds a stored value to something sane.
|
||||
*/
|
||||
fun custom(hourHeight: Dp): Custom =
|
||||
Custom(hourHeight.coerceIn(MIN_STORED_HOUR_HEIGHT, MAX_PINCH_HOUR_HEIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* A pinched height is held to [fillHourHeight] here and not only in the gesture,
|
||||
* because the viewport can change under a height that was already stored: pinch
|
||||
* all the way out in landscape and the same value would leave portrait with dead
|
||||
* space under midnight.
|
||||
*/
|
||||
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
|
||||
is TimelineScale.Custom -> this.hourHeight
|
||||
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||
}
|
||||
|
||||
/**
|
||||
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
||||
* to the hour it landed in.
|
||||
*
|
||||
* [hourPx] must be the hour height the column is drawing at *now* — a tap
|
||||
* detector that captured it when it was installed maps taps to a pre-pinch grid
|
||||
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
|
||||
* to read, so it answers midnight rather than dividing by it.
|
||||
*/
|
||||
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
||||
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* The hour height at which all 24 hours exactly fill [viewportHeight] — how far
|
||||
* out a pinch can zoom.
|
||||
*
|
||||
* Below it there is no more day left to uncover, so the only thing shrinking
|
||||
* further buys is empty space under midnight. Zooming out means "show me more of
|
||||
* the day", and once the whole day is on screen that request is answered.
|
||||
*/
|
||||
fun fillHourHeight(viewportHeight: Dp): Dp = viewportHeight / 24f
|
||||
|
||||
/**
|
||||
* Ceiling for a pinch, deliberately far above [TimelineScale.Comfortable]: a
|
||||
* pinch is a deliberate act, so someone zooming in on a single busy afternoon
|
||||
* should be allowed to go further than any preset offers.
|
||||
*/
|
||||
val MAX_PINCH_HOUR_HEIGHT = 240.dp
|
||||
|
||||
/**
|
||||
* Floor for a *stored* pinched height, which only has to stay sane enough to be
|
||||
* re-clamped against whatever viewport it is later shown in. The floor a pinch
|
||||
* stops at is [fillHourHeight].
|
||||
*/
|
||||
private val MIN_STORED_HOUR_HEIGHT = 1.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
|
||||
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||
}
|
||||
|
||||
@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
|
||||
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
|
||||
}
|
||||
|
||||
/** Marks a stored custom height; the rest of the value is its dp. */
|
||||
private const val CUSTOM_PREFIX = "custom:"
|
||||
|
||||
/**
|
||||
* Stored names for the presets. Spelled out rather than taken from `toString()`
|
||||
* so R8 can't rename them out from under an existing install — and they match
|
||||
* the enum names this used to be, so a value written before the pinch existed
|
||||
* still reads back as the same preset.
|
||||
*/
|
||||
private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
|
||||
TimelineScale.FitDay to "FitDay",
|
||||
TimelineScale.Compact to "Compact",
|
||||
TimelineScale.Regular to "Regular",
|
||||
TimelineScale.Comfortable to "Comfortable",
|
||||
)
|
||||
|
||||
/**
|
||||
* Serialise for the `timeline_scale` preference; see [parseTimelineScale].
|
||||
*
|
||||
* Spelled out rather than an `else` into [PRESET_NAMES], so a preset added later
|
||||
* without a stored name is a compile error here instead of a crash the first
|
||||
* time someone picks it.
|
||||
*/
|
||||
fun TimelineScale.storageValue(): String = when (this) {
|
||||
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||
TimelineScale.FitDay,
|
||||
TimelineScale.Compact,
|
||||
TimelineScale.Regular,
|
||||
TimelineScale.Comfortable,
|
||||
-> PRESET_NAMES.getValue(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stored scale. Anything unrecognised — a null key, a name from a
|
||||
* future release, a truncated custom height — falls back to
|
||||
* [TimelineScale.Regular] rather than throwing, like the other enum prefs.
|
||||
*/
|
||||
fun parseTimelineScale(stored: String?): TimelineScale = when {
|
||||
stored == null -> TimelineScale.Regular
|
||||
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
||||
?.let { TimelineScale.custom(it.dp) }
|
||||
?: TimelineScale.Regular
|
||||
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculateCentroid
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The scale the timelines draw at right now: the stored preference, or whatever
|
||||
* a pinch is currently doing to it (#56).
|
||||
*
|
||||
* A pinch changes the scale on every pointer frame, which the preference alone
|
||||
* cannot carry — a DataStore round trip per frame would lag the gesture badly
|
||||
* behind the fingers. So the live value lives here in composition state and only
|
||||
* the settled result is written back, once, when the fingers lift.
|
||||
*/
|
||||
@Stable
|
||||
class TimelineZoom(
|
||||
initial: TimelineScale,
|
||||
private val persist: (TimelineScale) -> Unit,
|
||||
) {
|
||||
/** What the week and day timelines should draw at. */
|
||||
var scale: TimelineScale by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
private var pinching = false
|
||||
|
||||
/**
|
||||
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
||||
* value is a frame or two behind the fingers there, and letting it land would
|
||||
* snap the timeline back while the user is still pinching.
|
||||
*/
|
||||
fun adopt(stored: TimelineScale) {
|
||||
if (!pinching) scale = stored
|
||||
}
|
||||
|
||||
fun beginPinch() {
|
||||
pinching = true
|
||||
}
|
||||
|
||||
fun pinchTo(hourHeight: Dp) {
|
||||
scale = TimelineScale.custom(hourHeight)
|
||||
}
|
||||
|
||||
fun endPinch() {
|
||||
pinching = false
|
||||
persist(scale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The zoom the timelines read. Falls back to a detached instance — one shared
|
||||
* lazy value, not one per read, or each read would hand out a fresh state — so a
|
||||
* preview or test can render a timeline without the activity's provider.
|
||||
*/
|
||||
private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {} }
|
||||
|
||||
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
||||
|
||||
/**
|
||||
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
|
||||
* back through [onPersist].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTimelineZoom(
|
||||
stored: TimelineScale,
|
||||
onPersist: (TimelineScale) -> Unit,
|
||||
): TimelineZoom {
|
||||
val persist by rememberUpdatedState(onPersist)
|
||||
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
||||
// Picking a preset in Settings has to reach the timelines, and so does the
|
||||
// stored value arriving after the first frame.
|
||||
LaunchedEffect(stored) { zoom.adopt(stored) }
|
||||
return zoom
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-finger pinch that rescales a 24-hour timeline (#56) — the gesture the
|
||||
* issue actually asked for; the Settings presets stay as the accessible route to
|
||||
* the same thing.
|
||||
*
|
||||
* Three gestures share this area, so the pinch is deliberately the fussiest
|
||||
* about claiming it: nothing happens until a *second* finger is down and the
|
||||
* spread has grown past [PINCH_SLOP], which leaves one-finger vertical scrolling
|
||||
* and the horizontal week swipe untouched, and lets a two-finger drag still
|
||||
* scroll. It watches on [PointerEventPass.Initial] because the scroll it has to
|
||||
* outrank is a descendant — on the main pass the scroll would have consumed the
|
||||
* drag before this ever saw it.
|
||||
*
|
||||
* [hourHeight] is the *resolved* height, so a pinch that starts from
|
||||
* `FitDay` picks up where the viewport left it rather than jumping.
|
||||
* [viewportHeight] sets how far out it can go — see [fillHourHeight].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTimelinePinchZoom(
|
||||
scrollState: ScrollState,
|
||||
viewportHeight: Dp,
|
||||
hourHeight: Dp,
|
||||
zoom: TimelineZoom,
|
||||
): Modifier {
|
||||
// The gesture loop outlives any single composition, so it reads these
|
||||
// through state handles rather than capturing what they were when it started.
|
||||
val currentHourHeight = rememberUpdatedState(hourHeight)
|
||||
val currentViewport = rememberUpdatedState(viewportHeight)
|
||||
return Modifier.pointerInput(scrollState, zoom) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
var claimed = false
|
||||
var slop = 1f
|
||||
// Scroll the layout could not give us yet, carried to the next frame
|
||||
// (see anchoredScroll).
|
||||
var pending = 0f
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
if (event.changes.none { it.pressed }) break
|
||||
if (event.changes.count { it.pressed } >= 2) {
|
||||
val step = event.calculateZoom()
|
||||
if (!claimed) {
|
||||
slop *= step
|
||||
if (abs(slop - 1f) >= PINCH_SLOP) {
|
||||
claimed = true
|
||||
zoom.beginPinch()
|
||||
}
|
||||
}
|
||||
if (claimed) {
|
||||
val old = currentHourHeight.value.toPx()
|
||||
val new = pinchedHourHeightPx(
|
||||
target = old * step,
|
||||
fillPx = fillHourHeight(currentViewport.value).toPx(),
|
||||
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
||||
)
|
||||
// Half a pixel, not equality: the height round-trips
|
||||
// through dp and back, and an exact test would read
|
||||
// the float noise that comes back as a scale change
|
||||
// and feed the scroll a delta on every frame of a
|
||||
// held pinch.
|
||||
if (abs(new - old) >= 0.5f) {
|
||||
val centroidY = event.calculateCentroid(useCurrent = true).y
|
||||
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
|
||||
scrollState.value
|
||||
zoom.pinchTo(new.toDp())
|
||||
}
|
||||
pending -= scrollState.dispatchRawDelta(pending)
|
||||
}
|
||||
}
|
||||
// Hold the gesture to the end once it has become a pinch:
|
||||
// letting go the moment a finger lifts would turn the tail of
|
||||
// a zoom into a scroll, and the taps underneath into a new event.
|
||||
if (claimed) event.changes.forEach { if (it.pressed) it.consume() }
|
||||
}
|
||||
} finally {
|
||||
// In a finally because the gesture can also end by having its
|
||||
// pointer node disposed mid-pinch — the timeline swapping out
|
||||
// under the fingers. The zoom outlives that node, and a pinch
|
||||
// left open would make it ignore every later Settings change.
|
||||
if (claimed) zoom.endPinch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a pinch aiming at [target] px per hour actually lands: clamped between
|
||||
* "the whole day fills the screen" ([fillPx]) and [maxPx], then rounded to a
|
||||
* whole pixel.
|
||||
*
|
||||
* The rounding is not cosmetic. The hour gutter is 24 stacked boxes one hour
|
||||
* tall, and each rounds its own height to whole pixels, while the hour lines and
|
||||
* event blocks are drawn at the fractional height — so a height of 56.4px lays
|
||||
* the labels out at 56px and leaves the 23:00 label ~9px above the line it
|
||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||
*
|
||||
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||
* that keeps its own promise — up for the fill floor, so no dead space opens
|
||||
* under midnight, down for the ceiling. A fractional bound would be a height the
|
||||
* pinch can be held against but never actually land on, and the difference feeds
|
||||
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||
*/
|
||||
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||
// the two to disagree, dead space is the worse of the two failures.
|
||||
target.roundToInt().toFloat()
|
||||
.coerceAtMost(floor(maxPx))
|
||||
.coerceAtLeast(ceil(fillPx))
|
||||
|
||||
/**
|
||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||
* hour height changes from [oldHourPx] to [newHourPx].
|
||||
*
|
||||
* Anchoring on the fingers is what makes a zoom feel like the day is being
|
||||
* stretched rather than replaced: without it, zooming in on the evening walks
|
||||
* the evening off the bottom of the screen.
|
||||
*
|
||||
* The caller may not get all of this at once — the content only grows on the
|
||||
* next layout pass, so scrolling further down than the *current* content allows
|
||||
* is refused — which is why the shortfall is carried over and re-offered.
|
||||
*/
|
||||
internal fun anchoredScroll(
|
||||
scroll: Int,
|
||||
centroidY: Float,
|
||||
oldHourPx: Float,
|
||||
newHourPx: Float,
|
||||
): Float = ((scroll + centroidY) / oldHourPx) * newHourPx - centroidY
|
||||
|
||||
/**
|
||||
* How far the fingers must spread or close before the pinch takes over. Small
|
||||
* enough to feel immediate, wide enough that the two-finger scroll a user meant
|
||||
* as a scroll stays one.
|
||||
*/
|
||||
private const val PINCH_SLOP = 0.08f
|
||||
@@ -45,7 +45,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -89,15 +88,9 @@ 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.LocalTimelineZoom
|
||||
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.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -108,11 +101,13 @@ 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
|
||||
|
||||
@@ -201,7 +196,6 @@ fun DayScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = date,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -272,6 +266,7 @@ private fun DayContent(
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -282,9 +277,11 @@ private fun DayContent(
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
@@ -492,26 +489,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 zoom = LocalTimelineZoom.current
|
||||
|
||||
// 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 = zoom.scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
||||
// outrank the vertical scroll, and it does that by watching the initial
|
||||
// pass, which only reaches it if it is their ancestor.
|
||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 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
|
||||
// soft corners are permanent at any scroll position.
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||
// labels centre on the top bar hamburger, matching the week view.
|
||||
Column(
|
||||
@@ -525,7 +513,7 @@ private fun Timeline(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
.height(HOUR_HEIGHT),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
@@ -553,7 +541,6 @@ private fun Timeline(
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
modifier = Modifier
|
||||
@@ -571,20 +558,13 @@ 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) { hourHeight.toPx() }
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
// the height and the callback through state handles instead of capturing
|
||||
// them, or taps land on the scale the column had before the zoom (#148).
|
||||
val currentHourPx = rememberUpdatedState(hourPx)
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -605,24 +585,20 @@ private fun DayColumnCard(
|
||||
// only fires on the column background. Snaps to the tapped hour.
|
||||
.pointerInput(date) {
|
||||
detectTapGestures { offset ->
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||
onCreateAt(date, hour * 60)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
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
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
@@ -633,7 +609,7 @@ private fun DayColumnCard(
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -643,7 +619,6 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -652,20 +627,7 @@ private fun EventBlock(
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
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 showTime = block.endMin - block.startMin >= 45
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(block.event.color, dark, soften)
|
||||
Box(
|
||||
@@ -676,22 +638,20 @@ private fun EventBlock(
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
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 = SECONDARY_INK_ALPHA),
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -700,22 +660,17 @@ private fun EventBlock(
|
||||
|
||||
@Composable
|
||||
private fun DayLoading() {
|
||||
val scale = LocalTimelineZoom.current.scale
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scrollState = rememberScrollState()
|
||||
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),
|
||||
)
|
||||
}
|
||||
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,7 +100,6 @@ 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
|
||||
@@ -387,7 +386,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
val locale = currentDetailLocale()
|
||||
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
|
||||
@@ -133,7 +133,6 @@ 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
|
||||
@@ -564,7 +563,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 { eventAccent(it.color, dark, soften) }
|
||||
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
@@ -74,12 +73,7 @@ private fun FilterList(
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 12.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
group.calendars.forEachIndexed { index, cal ->
|
||||
GroupedRow(
|
||||
@@ -109,7 +103,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||
repeat(4) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.padding(horizontal = 28.dp)
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(
|
||||
@@ -135,6 +129,6 @@ private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier)
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset, vertical = 24.dp),
|
||||
.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ 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
|
||||
@@ -331,7 +330,6 @@ fun MonthScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -1481,7 +1479,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(eventAccent(event.color, dark, soften), CircleShape),
|
||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
if (hidden.isNotEmpty()) {
|
||||
@@ -2106,7 +2104,7 @@ private fun OverflowDots(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||
.background(eventFill(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
val extra = total - dots.size
|
||||
|
||||
@@ -56,7 +56,6 @@ 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
|
||||
@@ -209,7 +208,7 @@ private fun SearchResultRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
|
||||
@@ -58,8 +57,6 @@ 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,7 +36,6 @@ 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
|
||||
@@ -171,9 +170,8 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.drawerViewOrder,
|
||||
prefs.monthViewStyle,
|
||||
prefs.widgetSize,
|
||||
prefs.timelineScale,
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
@@ -197,7 +195,6 @@ 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,
|
||||
@@ -312,7 +309,6 @@ 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. */
|
||||
@@ -599,10 +595,6 @@ 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,9 +33,7 @@ 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
|
||||
@@ -70,7 +68,6 @@ 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),
|
||||
@@ -147,16 +144,10 @@ 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.Bottom,
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
@@ -330,25 +321,6 @@ 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,
|
||||
// A height pinched on the timeline is listed alongside the presets
|
||||
// rather than left as a silently unticked list: it is the current
|
||||
// setting, so it has to be visible here, and seeing it next to the
|
||||
// named steps is what makes "tap one to go back" obvious.
|
||||
options = TimelineScale.presets + listOfNotNull(
|
||||
state.timelineScale as? TimelineScale.Custom,
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -51,7 +51,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -99,16 +98,9 @@ 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.LocalTimelineZoom
|
||||
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.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
@@ -122,12 +114,15 @@ 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
|
||||
|
||||
@@ -222,7 +217,6 @@ fun WeekScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = weekStart,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -302,6 +296,7 @@ private fun WeekContent(
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -313,9 +308,11 @@ private fun WeekContent(
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
@@ -627,27 +624,18 @@ 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 zoom = LocalTimelineZoom.current
|
||||
|
||||
// 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 = zoom.scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
||||
// outrank the vertical scroll, and it does that by watching the initial
|
||||
// pass, which only reaches it if it is their ancestor.
|
||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 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
|
||||
// soft corners are permanent at any scroll position (not just at the
|
||||
// day's start/end).
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||
// as the header badge so the labels sit under it and on the hamburger.
|
||||
Column(
|
||||
@@ -661,7 +649,7 @@ private fun Timeline(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
.height(HOUR_HEIGHT),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
@@ -696,7 +684,6 @@ private fun Timeline(
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
modifier = Modifier
|
||||
@@ -716,20 +703,13 @@ 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) { hourHeight.toPx() }
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
// the height and the callback through state handles instead of capturing
|
||||
// them, or taps land on the scale the column had before the zoom (#148).
|
||||
val currentHourPx = rememberUpdatedState(hourPx)
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -749,25 +729,21 @@ private fun DayColumnCard(
|
||||
// blocks are consumed by their own handler first. Snaps to hour.
|
||||
.pointerInput(date) {
|
||||
detectTapGestures { offset ->
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||
onCreateAt(date, hour * 60)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
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
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
width = laneWidth,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
@@ -778,7 +754,7 @@ private fun DayColumnCard(
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -789,7 +765,6 @@ private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -798,6 +773,10 @@ 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()
|
||||
@@ -805,30 +784,11 @@ private fun EventBlock(
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
@@ -841,22 +801,20 @@ private fun EventBlock(
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
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 = SECONDARY_INK_ALPHA),
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -865,7 +823,7 @@ private fun EventBlock(
|
||||
|
||||
@Composable
|
||||
private fun WeekLoading() {
|
||||
val scale = LocalTimelineZoom.current.scale
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scrollState = rememberScrollState()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header skeleton
|
||||
@@ -884,21 +842,16 @@ private fun WeekLoading() {
|
||||
)
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
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,7 +58,6 @@ 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
|
||||
@@ -383,7 +382,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 = eventAccent(event.color, dark, soften).let {
|
||||
val stripeColor = eventFill(event.color, dark, soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<string name="month_prev">الشهر السابق</string>
|
||||
<string name="month_next">الشهر القادم</string>
|
||||
<string name="month_today_action">اليوم</string>
|
||||
<string name="month_action_settings">الإعدادات</string>
|
||||
<string name="month_action_settings">إعدادات</string>
|
||||
<string name="settings_title">الإعدادات</string>
|
||||
<string name="settings_theme">الثيم</string>
|
||||
<string name="settings_theme_system">النظام</string>
|
||||
@@ -137,7 +137,7 @@
|
||||
<string name="event_detail_calendar">التقويم</string>
|
||||
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
|
||||
<string name="event_detail_description">الوصف</string>
|
||||
<string name="event_detail_all_day">طوال اليوم</string>
|
||||
<string name="event_detail_all_day">كل يوم</string>
|
||||
<string name="event_detail_location">الموقع</string>
|
||||
<string name="event_detail_attendees">الحضور</string>
|
||||
<string name="event_detail_recurrence">التكرار</string>
|
||||
@@ -219,8 +219,8 @@
|
||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
|
||||
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
||||
<string name="settings_soften_colors">ألوان تقويم متناسقة</string>
|
||||
<string name="settings_soften_colors_summary">حافظ على درجة لون كل تقويم لكن قم بتسوية سطوعه، بحيث كل حدث يظل مقروءًا و تتناسق الألوان معًا. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
||||
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||
<string name="settings_font_headings">خط العناوين</string>
|
||||
<string name="settings_font_body">خط النص</string>
|
||||
<string name="settings_font_system">افتراضي النظام</string>
|
||||
@@ -297,249 +297,4 @@
|
||||
<string name="settings_agenda_range">مدى الجدول</string>
|
||||
<string name="agenda_range_custom">مخصص…</string>
|
||||
<string name="agenda_range_custom_hint">أيام</string>
|
||||
<string name="agenda_range_week">٧ أيام القادمة</string>
|
||||
<string name="agenda_range_month">٣٠ يومًا القادمة</string>
|
||||
<string name="settings_month_view_style">نمط عرض الشهر</string>
|
||||
<string name="month_style_paged">صفحات</string>
|
||||
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر.</string>
|
||||
<string name="month_style_split">تقسيم</string>
|
||||
<string name="settings_color_unsupported">السماح بالألوان في التقويمات غير المدعومة</string>
|
||||
<string name="settings_color_unsupported_hint">بعض التقويمات (مثل البعض من CalDAV) لا تنشر أي مجموعة ألوان؛ لون حدث مخصص قد يزال أو يستبدل في مزامنته التالية. هذا قيد على تلك التقويمات، وليس شيئًا يمكن لـ Calendula إصلاحه.</string>
|
||||
<string name="settings_section_views">طُرق العرض</string>
|
||||
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
|
||||
<string name="settings_reminders">تذكيرات الحدث</string>
|
||||
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
||||
<string name="reminder_custom_set">تعيين</string>
|
||||
<string name="settings_autofocus_title_hint">عند بدء حدث جديد، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
|
||||
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\"</string>
|
||||
<string name="settings_section_event_form">نموذج حدث جديد</string>
|
||||
<string name="settings_quick_switch_header">زر التبديل السريع</string>
|
||||
<string name="app_name">التقويم</string>
|
||||
<string name="settings_theme_system_summary">الحالي %1$s</string>
|
||||
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
|
||||
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
|
||||
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
|
||||
<string name="settings_hour_lines">خطوط الساعات</string>
|
||||
<string name="settings_hour_lines_summary">إظهار خط فاصل في كل ساعة في طريقة عرض الأسبوع واليوم</string>
|
||||
<string name="timeline_scale_fit_day_summary">جميع الـ 24 ساعة على شاشة واحدة، لا تمرير</string>
|
||||
<string name="timeline_scale_custom">مخصص</string>
|
||||
<string name="settings_widget_size_small">صغير</string>
|
||||
<string name="settings_widget_size_medium">متوسط</string>
|
||||
<string name="settings_widget_size_large">كبير</string>
|
||||
<string name="settings_widget_size_extra_large">كبير جدًا</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="zero">(%d) لا أيام</item>
|
||||
<item quantity="one">(%d) يوم واحد</item>
|
||||
<item quantity="two">%d يومان</item>
|
||||
<item quantity="few">%d أيام</item>
|
||||
<item quantity="many">%d يوم</item>
|
||||
<item quantity="other">%d يوم</item>
|
||||
</plurals>
|
||||
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل النطاق للجلسة</string>
|
||||
<string name="settings_agenda_range_bar">شريط النطاق</string>
|
||||
<string name="settings_month_header">عرض الشهر</string>
|
||||
<string name="month_style_continuous">تمرير الشهور</string>
|
||||
<string name="month_style_dense">أسابيع سلسة</string>
|
||||
<string name="month_style_continuous_summary">كل شهر يقع تحت عنوانه الخاص، مع مساحة صغيرة تميزه عن الشهر التالي.</string>
|
||||
<string name="month_style_dense_summary">تمر الأسابيع دون انقطاع، ويتدفق كل شهر مباشرة إلى الشهر التالي دون وجود فجوة بينهما.</string>
|
||||
<string name="month_split_no_events">لا شيء مجدول</string>
|
||||
<string name="month_style_split_summary">شبكة مدمجة تحدد الأيام التي تتضمن أحداثًا، ويتم إدراج اليوم الذي تنقر عليه تحتها.</string>
|
||||
<string name="settings_autofocus_title">تركيز العنوان على حدث جديد</string>
|
||||
<string name="settings_event_duration">المدة الافتراضية</string>
|
||||
<string name="settings_event_duration_hint">مدة الحدث الجديد تبقى كما هي حتى أنت تغيير وقت نهايته. أما أحداث التي هي طوال اليوم فلن تتأثر.</string>
|
||||
<string name="settings_section_notifications">الإشعارات</string>
|
||||
<string name="settings_reminders_hint">ترى التذكيرات مرتين؟ هناك تطبيق تقويم آخر ينشرهم أيضًا — قم بإيقافهم في أحد من الاثنين.</string>
|
||||
<string name="settings_group_look">المظهر و السلوك</string>
|
||||
<string name="settings_group_data">البيانات</string>
|
||||
<string name="settings_group_app">التطبيق</string>
|
||||
<string name="settings_language">لغة التطبيق</string>
|
||||
<string name="settings_dynamic_color_summary">أخذ ألوان التطبيق من خلفيتك.</string>
|
||||
<string name="settings_group_about">حول</string>
|
||||
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">المصدر</string>
|
||||
<string name="settings_about_privacy">سياسة الخصوصية</string>
|
||||
<string name="settings_about_support">دَعم التطوير</string>
|
||||
<string name="settings_about_version">الإصدار %1$s</string>
|
||||
<string name="settings_about_logo_desc">رمز تطبيق Calendula</string>
|
||||
<string name="crash_report_body_template">شكرًا لإبلاغ عن عطل في %1$s. يُرجى إضافة أي شيء تتذكره حول ما كنت تفعله، ثم إرسال.\n\n### ماذا حدث\n\n\n### تقرير العطل\n%2$s\n</string>
|
||||
<string name="settings_section_about">حول</string>
|
||||
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
|
||||
<string name="settings_report_problem_hint">أرسل تقرير الأعطال أو افتح متعقب المشكلات</string>
|
||||
<string name="settings_language_auto">افتراضي النظام</string>
|
||||
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
|
||||
<string name="settings_special_dates_enable">إظهار تواريخ جهات الاتصال</string>
|
||||
<string name="settings_special_dates_enable_hint">اعكس أعياد ميلاد جهات اتصالك والتواريخ الأخرى إلى التقويمات المحلية. يقرأ جهات الاتصال على هذا الجهاز فقط — لا يتم رفع أي شئ، وجهات اتصالك لا يتم تغييرها أبدًا.</string>
|
||||
<string name="settings_section_language">اللغة</string>
|
||||
<string name="qs_tile_new_event_label">حدث جديد</string>
|
||||
<string name="shortcut_new_event_long">إنشاء حدث جديد</string>
|
||||
<string name="shortcut_new_event_short">حدث جديد</string>
|
||||
<string name="settings_qs_tile_hint">أضف مربع ”حدث جديد“ إلى لوحة الإعدادات السريعة.</string>
|
||||
<string name="calendars_local_header">تقويماتك</string>
|
||||
<string name="settings_section_special_dates">تواريخ جهات الاتصال الخاصة</string>
|
||||
<string name="calendars_title">التقويمات</string>
|
||||
<string name="calendars_add">إضافة تقويم</string>
|
||||
<string name="settings_section_calendars">التقويمات</string>
|
||||
<string name="settings_manage_calendars">إدارة التقويمات</string>
|
||||
<string name="event_edit_recurrence_next">التالي: %1$s</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="zero">(%d) لا دقائق قبل</item>
|
||||
<item quantity="one">(%d) دقيقة واحده قبل</item>
|
||||
<item quantity="two">%d دقيقتان قبل</item>
|
||||
<item quantity="few">%d دقائق قبل</item>
|
||||
<item quantity="many">%d دقيقة قبل</item>
|
||||
<item quantity="other">%d دقيقة قبل</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="zero">(%d) لا ساعات قبل</item>
|
||||
<item quantity="one">(%d) ساعة واحده قبل</item>
|
||||
<item quantity="two">%d ساعتان قبل</item>
|
||||
<item quantity="few">%d ساعات قبل</item>
|
||||
<item quantity="many">%d ساعة قبل</item>
|
||||
<item quantity="other">%d ساعة قبل</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="zero">(%d) لا أيام قبل</item>
|
||||
<item quantity="one">(%d) يوم واحد قبل</item>
|
||||
<item quantity="two">%d يومان قبل</item>
|
||||
<item quantity="few">%d أيام قبل</item>
|
||||
<item quantity="many">%d يوم قبل</item>
|
||||
<item quantity="other">%d يوم قبل</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="zero">(%d) لا أسابيع قبل</item>
|
||||
<item quantity="one">(%d) أسبوع واحد قبل</item>
|
||||
<item quantity="two">%d أسبوعان قبل</item>
|
||||
<item quantity="few">%d أسابيع قبل</item>
|
||||
<item quantity="many">%d أسبوع قبل</item>
|
||||
<item quantity="other">%d أسبوع قبل</item>
|
||||
</plurals>
|
||||
<string name="reminder_benefit_reversible_body">يوجد المفتاح في الإعدادات، ضمن الإشعارات.</string>
|
||||
<string name="reminder_custom_amount">القيمة</string>
|
||||
<string name="settings_manage_calendars_hint">إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة</string>
|
||||
<string name="settings_snooze_duration">مدة التأجيل</string>
|
||||
<string name="settings_calendar_reminder_inherits">الافتراضي (%1$s)</string>
|
||||
<string name="settings_translate">المساعدة في الترجمة</string>
|
||||
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
|
||||
<string name="settings_appearance_subtitle">الثيم، الألوان، الخطوط</string>
|
||||
<string name="settings_views_subtitle">العرض، التخطيط، الترتيب</string>
|
||||
<string name="settings_event_form_subtitle">الخانات الافتراضية و السلوك</string>
|
||||
<string name="settings_notifications_subtitle">التذكيرات و التسليم</string>
|
||||
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و احتفالات ذكرى</string>
|
||||
<string name="settings_special_dates_type_birthday">أعياد الميلاد</string>
|
||||
<string name="settings_special_dates_type_custom">تواريخ أخرى</string>
|
||||
<string name="settings_license">الترخيص</string>
|
||||
<string name="settings_special_dates_disable_title">إيقاف تشغيل تواريخ جهات الاتصال؟</string>
|
||||
<string name="settings_special_dates_disable_confirm">إيقاف</string>
|
||||
<string name="settings_special_dates_sync_now">زامن الآن</string>
|
||||
<string name="settings_special_dates_never_synced">لم تتم المزامنة بعد</string>
|
||||
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات الاتصال الخاصة بك، لذلك لا يتم تحديث هذه التقويمات.</string>
|
||||
<string name="calendars_manage_in_app">الإدارة في التطبيق</string>
|
||||
<string name="calendars_enable_all">تفعيل الكل</string>
|
||||
<string name="calendars_disable_all">تعطيل الكل</string>
|
||||
<string name="calendars_add_account">إضافة حساب</string>
|
||||
<string name="calendars_new_title">تقويم جديد</string>
|
||||
<string name="calendars_edit_title">تعديل التقويم</string>
|
||||
<string name="calendars_name_label">الاسم</string>
|
||||
<string name="calendars_color_label">اللون</string>
|
||||
<string name="calendars_description_hint">أضف وصفًا</string>
|
||||
<string name="calendars_delete_confirm_title">حذف التقويم؟</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" وجميع أحداثه ستتم إزالتها نهائيًا من هذا الجهاز.</string>
|
||||
<string name="calendars_write_error">تعذّر حفظ التغيير.</string>
|
||||
<string name="calendars_backup_header">النسخ الاحتياطي</string>
|
||||
<string name="calendars_backup_hint">التقويمات المحلية لا تتم مزامنتها في أي مكان، لذا قم بتصديرها إلى ملف .ics للاحتفاظ بنسخة.</string>
|
||||
<string name="dialog_save">حفظ</string>
|
||||
<string name="settings_special_dates_grant">منح الوصول</string>
|
||||
<string name="calendars_backup_action">تصدير كملف .ics</string>
|
||||
<string name="calendars_export_title">تصدير التقويمات</string>
|
||||
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في .ics الملف.</string>
|
||||
<string name="calendars_export_action">تصدير</string>
|
||||
<string name="calendars_restore_header">استعادة</string>
|
||||
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
||||
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
|
||||
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
|
||||
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ .ics الملف.</string>
|
||||
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
|
||||
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
|
||||
<string name="calendars_auto_backup_every">كل %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">الحد الأدني ٣٠ دقيقة.</string>
|
||||
<string name="calendars_auto_backup_status_never">لا نسخ احتياطي تلقائي بعد</string>
|
||||
<string name="calendars_auto_backup_status_ok">آخر نسخة احتياطية: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">فشل آخر نسخ احتياطي: %1$s</string>
|
||||
<string name="backup_channel_name">النسخ الاحتياطي</string>
|
||||
<string name="backup_channel_description">يحذّر إذا تفشل النسخ الاحتياطي التلقائي بشكل متكرر.</string>
|
||||
<string name="backup_failed_title">فشل النسخ الاحتياطي التلقائي</string>
|
||||
<string name="backup_failed_text">Calendula تعذَّر في كتابة ملف النسخ الاحتياطي. تحقق من مجلد النسخ الاحتياطي في الإعدادات.</string>
|
||||
<string name="calendars_backup_failed">تعذّر تصدير النسخة الاحتياطية.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="zero">تم تصدير (%d) لا أحداث.</item>
|
||||
<item quantity="one">تم تصدير (%d) حدث واحد.</item>
|
||||
<item quantity="two">تم تصدير %d حدثان.</item>
|
||||
<item quantity="few">تم تصدير %d أحداث.</item>
|
||||
<item quantity="many">تم تصدير %d حدث.</item>
|
||||
<item quantity="other">تم تصدير %d حدث.</item>
|
||||
</plurals>
|
||||
<string name="import_title">استيراد الأحداث</string>
|
||||
<string name="import_target_header">إضافة إلى تقويم</string>
|
||||
<string name="import_empty">لم يتم العثور على أحداث في هذا الملف.</string>
|
||||
<string name="import_failed">تعذَّر قراءة هذا الملف.</string>
|
||||
<string name="import_no_calendar">لا تقويم قابل للكتابة لاستيراد إليه. أنشئ تقويمًا محليًا أولاً.</string>
|
||||
<string name="import_done_title">اكتمل الاستيراد</string>
|
||||
<string name="import_done_dedup_note">تم تخطي الأحداث الموجودة بالفعل في التقويم.</string>
|
||||
<string name="settings_special_dates_reminders">التذكيرات</string>
|
||||
<string name="calendars_account_menu_a11y">المزيد من الخيارات لـ %1$s</string>
|
||||
<string name="calendars_synced_hint">تأتي هذه من الحسابات الموجودة على جهازك. يمكنك إنشاؤها وتعديلها في تطبيقها الخاص.</string>
|
||||
<string name="calendars_synced_header">التقويمات المتزامنة</string>
|
||||
<string name="calendars_local_empty">لا تقويمات محلية حتى الآن. أنشئ واحدًا للاحتفاظ بالأحداث على هذا الجهاز فقط.</string>
|
||||
<string name="calendars_auto_backup_interval">الفاصل الزمني</string>
|
||||
<string name="import_warning_no_start">تم تخطي حدث بدون وقت بدء.</string>
|
||||
<string name="import_warning_recurrence">تم تخطي بعض التكرارات التي تم تغييرها للأحداث المتكررة.</string>
|
||||
<string name="import_close">إغلاق</string>
|
||||
<string name="import_done_skipped_label">التكرارات</string>
|
||||
<string name="import_done_added_label">أُضيف</string>
|
||||
<string name="crash_dialog_title">%1$s تعطل</string>
|
||||
<string name="settings_qs_tile">إضافة مربع الإعدادات السريعة</string>
|
||||
<string name="import_button">استيراد</string>
|
||||
<string name="crash_dialog_dismiss">ليس الآن</string>
|
||||
<string name="crash_report_copied">تم نسخ التقرير إلى الحافظة</string>
|
||||
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في الحافظة الخاصة بك.</string>
|
||||
<string name="crash_report_issue_title">تقرير العطل</string>
|
||||
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
|
||||
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من الحافظة هنا.)_</string>
|
||||
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
|
||||
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
|
||||
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
|
||||
<string name="event_access_public_summary">يري كل شخص لديه حق الوصول التفاصيل الكاملة</string>
|
||||
<string name="event_access_private_summary">الآخرون يرون فقط أنك مشغول</string>
|
||||
<string name="event_access_default_summary">أينما كان ما يفعله هذا التقويم عادةً</string>
|
||||
<string name="month_split_collapse">عرض أحداث اليوم</string>
|
||||
<string name="month_split_expand">إظهار الشهر بأكمله</string>
|
||||
<string name="settings_week_day_header">الأسبوع و اليوم</string>
|
||||
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
|
||||
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طرق العرض والودجتز.</string>
|
||||
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
|
||||
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
|
||||
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula يعرض الآن التقويمات التي تم تشغيلها لهذا الجهاز، لذلك ما تراه وما يذكرك لم يعد بإمكانه أن يختلف. بعض تقويماتك متوقفة حاليًا — تم إيقافها هنا أو في تطبيق تقويم آخر. أعد تشغيل أي منها في الإعدادات ← التقويمات.</string>
|
||||
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
|
||||
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا.</string>
|
||||
<string name="calendars_state_read_only">للقراءة فقط</string>
|
||||
<string name="calendars_state_not_synced">لم تتم المزامنة مع هذا الجهاز</string>
|
||||
<string name="calendars_state_managed">تم ملؤه من جهات اتصالك</string>
|
||||
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات الاتصال الخاصة بك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
|
||||
<string name="duration_custom_max">كحد أقصى %1$s</string>
|
||||
<string name="settings_calendar_duration_inherits">الافتراضي (%1$s)</string>
|
||||
<string name="settings_calendar_duration_use_default">استخدام المدة الافتراضية (%1$s)</string>
|
||||
<string name="reminder_benefit_delivery_title">التذكيرات، تُسلَّم</string>
|
||||
<string name="reminder_use_default">استخدام التذكير الافتراضي</string>
|
||||
<string name="settings_timeline_scale">ارتفاع الساعة</string>
|
||||
<string name="settings_timeline_scale_hint">كم مدى المساحة العمودية التي تأخذها الساعة الواحده في عرض الأسبوع واليوم. كلا العرضين يشتركا في هذا الإعداد. يمكنك أيضًا الضغط على المخطط الزمني بإصبعين لتعيين أي ارتفاع بين.</string>
|
||||
<string name="timeline_scale_fit_day">الملائمة لليوم بأكمله</string>
|
||||
<string name="timeline_scale_compact">مضغوط</string>
|
||||
<string name="timeline_scale_regular_summary">المساحة القياسية</string>
|
||||
<string name="timeline_scale_comfortable">مُريَّح</string>
|
||||
<string name="timeline_scale_compact_summary">المزيد من الساعات لكل شاشة، ومجموعات أصغر</string>
|
||||
<string name="timeline_scale_regular">العادي</string>
|
||||
<string name="timeline_scale_comfortable_summary">مجموعات أوسع، تمرير أكثر</string>
|
||||
<string name="timeline_scale_custom_summary">الارتفاع الذي قمت بضغظت المخطط الزمني إليه</string>
|
||||
</resources>
|
||||
|
||||
@@ -490,12 +490,4 @@
|
||||
</plurals>
|
||||
<string name="event_detail_duplicate">Duplikate</string>
|
||||
<string name="reminder_day_tomorrow">Morgen</string>
|
||||
<string name="event_edit_timezone_device">Zeitzone des Gerätes</string>
|
||||
<string name="event_edit_timezone_device_summary">Passt sich an wo Sie sind</string>
|
||||
<string name="event_edit_timezone_search">Zeitzonen durchsuchen</string>
|
||||
<string name="event_edit_timezone_recent">Kürzliche</string>
|
||||
<string name="event_edit_timezone_all">Alle Zeitzonen</string>
|
||||
<string name="event_edit_timezone_none">Keine Zeitzone passt zu “%1$s”</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s deiner Zeit</string>
|
||||
<string name="import_reminder_prompt_title">Deine Standarterinnerung anwenden?</string>
|
||||
</resources>
|
||||
|
||||
@@ -491,5 +491,4 @@
|
||||
<item quantity="many">Importando %d eventos</item>
|
||||
<item quantity="other">Importando %d eventos</item>
|
||||
</plurals>
|
||||
<string name="settings_soften_colors">Armoniza los colores del calendario</string>
|
||||
</resources>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<string name="event_detail_share">Partager</string>
|
||||
<string name="event_share_chooser_title">Evénement partagé</string>
|
||||
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
||||
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
||||
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
||||
@@ -95,14 +95,14 @@
|
||||
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
||||
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
||||
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
||||
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
||||
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
||||
<string name="event_edit_gone_title">Evénement supprimé</string>
|
||||
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
||||
@@ -178,7 +178,7 @@
|
||||
<string name="event_access_confidential">Confidentiel</string>
|
||||
<string name="event_attendee_organizer">Organisateur</string>
|
||||
<string name="event_attendee_resource">Ressource</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="reminder_default">Rappel par défaut</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minute avant</item>
|
||||
@@ -283,7 +283,7 @@
|
||||
<string name="event_status_tentative">Provisoire</string>
|
||||
<string name="reminder_at_time">Au moment de l’événement</string>
|
||||
<string name="reminder_onboarding_body">Android n’affiche pas de rappels d’événements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
||||
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
||||
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
||||
@@ -331,14 +331,14 @@
|
||||
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
||||
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
||||
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
||||
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
||||
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
||||
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. C’est une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
<string name="settings_reminders">rappels d\'événements</string>
|
||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||
<string name="settings_default_reminder">rappel par défaut</string>
|
||||
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
||||
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
||||
@@ -383,9 +383,9 @@
|
||||
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
||||
<string name="settings_special_dates_paused_title">suspendu</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
||||
<string name="settings_section_about">A propos</string>
|
||||
<string name="settings_license">Licence</string>
|
||||
@@ -411,7 +411,7 @@
|
||||
<string name="calendars_edit_title">Editer un agenda</string>
|
||||
<string name="calendars_name_label">Nom</string>
|
||||
<string name="calendars_description_hint">Ajouter une description</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
||||
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
||||
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
||||
@@ -429,8 +429,8 @@
|
||||
<string name="calendars_auto_backup_interval">Intervalle</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
||||
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
||||
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
||||
@@ -481,7 +481,7 @@
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">créer un événement</string>
|
||||
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="crash_dialog_title">%1$s a planté</string>
|
||||
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
||||
<string name="crash_dialog_report">rapport</string>
|
||||
|
||||
@@ -334,8 +334,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">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_soften_colors">Soften calendar colours</string>
|
||||
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
|
||||
<string name="settings_font_headings">Headings font</string>
|
||||
<string name="settings_font_body">Body font</string>
|
||||
<string name="settings_font_system">System default</string>
|
||||
@@ -367,18 +367,6 @@
|
||||
<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. You can also pinch the timeline with two fingers to set any height in between.</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="timeline_scale_custom">Custom</string>
|
||||
<string name="timeline_scale_custom_summary">The height you pinched the timeline to</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>
|
||||
|
||||
@@ -5,7 +5,6 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
@@ -14,7 +13,6 @@ 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
|
||||
@@ -100,31 +98,6 @@ 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 `a pinched timeline height round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
// The pinch settles on a height between the presets, so the preference
|
||||
// has to store the number, not just a named step (#56).
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
val pinched = TimelineScale.custom(63.5f.dp)
|
||||
prefs.setTimelineScale(pinched)
|
||||
assertThat(prefs.timelineScale.first()).isEqualTo(pinched)
|
||||
|
||||
// …and a preset picked afterwards replaces it, rather than the two
|
||||
// coexisting with one silently winning.
|
||||
prefs.setTimelineScale(TimelineScale.Comfortable)
|
||||
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Comfortable)
|
||||
}
|
||||
|
||||
@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 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).
|
||||
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).
|
||||
val curated = listOf(
|
||||
EventColorOption("black", 0xFF000000.toInt()),
|
||||
EventColorOption("gray", 0xFF808080.toInt()),
|
||||
@@ -86,11 +86,8 @@ class EventColorPaletteTest {
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).curatedForPicker().map { it.key }
|
||||
|
||||
assertThat(curated).doesNotContain("gray") // paints as black's grey
|
||||
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
|
||||
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
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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.presets) {
|
||||
val hour = scale.hourHeight(phoneViewport)
|
||||
assertThat((hour * MIN_EVENT_FRACTION) / hour).isWithin(0.001f).of(MIN_EVENT_FRACTION)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinched height is used as given`() {
|
||||
assertThat(TimelineScale.custom(63.dp).hourHeight(phoneViewport)).isEqualTo(63.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinched height never leaves the timeline short of the screen`() {
|
||||
// Zooming out means "show me more of the day"; once the whole day is on
|
||||
// screen that is answered, and going further would only open dead space
|
||||
// under midnight.
|
||||
val tooSmall = TimelineScale.custom(4.dp)
|
||||
assertThat(tooSmall.hourHeight(phoneViewport) * 24).isAtLeast(phoneViewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a height pinched on one viewport still fills a taller one`() {
|
||||
// Pinching all the way out in landscape stores a small height; rotating
|
||||
// back to portrait must not leave the day floating in the top half.
|
||||
val pinchedInLandscape = TimelineScale.custom(fillHourHeight(320.dp))
|
||||
assertThat(pinchedInLandscape.hourHeight(phoneViewport) * 24).isAtLeast(phoneViewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch can zoom in past every preset`() {
|
||||
// Otherwise the gesture would be strictly less capable than the picker
|
||||
// it is meant to refine.
|
||||
val presetHeights = TimelineScale.presets.map { it.hourHeight(phoneViewport) }
|
||||
assertThat(MAX_PINCH_HOUR_HEIGHT).isGreaterThan(presetHeights.max())
|
||||
assertThat(TimelineScale.custom(200.dp).hourHeight(phoneViewport)).isEqualTo(200.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every scale round-trips through storage`() {
|
||||
for (scale in TimelineScale.presets + TimelineScale.custom(63.5f.dp)) {
|
||||
assertThat(parseTimelineScale(scale.storageValue())).isEqualTo(scale)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scales stored before the pinch existed still read back`() {
|
||||
// These were enum names once. An install that picked one must not be
|
||||
// silently reset to the default by the sealed-type rewrite.
|
||||
assertThat(parseTimelineScale("FitDay")).isEqualTo(TimelineScale.FitDay)
|
||||
assertThat(parseTimelineScale("Compact")).isEqualTo(TimelineScale.Compact)
|
||||
assertThat(parseTimelineScale("Regular")).isEqualTo(TimelineScale.Regular)
|
||||
assertThat(parseTimelineScale("Comfortable")).isEqualTo(TimelineScale.Comfortable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable stored scale falls back to the default`() {
|
||||
for (stored in listOf(null, "", "Roomy", "custom:", "custom:huge")) {
|
||||
assertThat(parseTimelineScale(stored)).isEqualTo(TimelineScale.Regular)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap creates at the hour it landed in, at whatever scale`() {
|
||||
// Same point on the same column reads as a different hour once the
|
||||
// timeline has been pinched — which is the whole of #148: the tap has to
|
||||
// be measured against the height the column is drawing at now.
|
||||
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 100f)).isEqualTo(5 * 60)
|
||||
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 50f)).isEqualTo(10 * 60)
|
||||
// Within an hour it snaps back to that hour's start.
|
||||
assertThat(tappedMinuteOfDay(offsetY = 599f, hourPx = 100f)).isEqualTo(5 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap outside the day stays inside it`() {
|
||||
assertThat(tappedMinuteOfDay(offsetY = -20f, hourPx = 56f)).isEqualTo(0)
|
||||
assertThat(tappedMinuteOfDay(offsetY = 99_999f, hourPx = 56f)).isEqualTo(23 * 60)
|
||||
// A viewport measured at nothing has no grid to read — midnight, not a
|
||||
// division by zero.
|
||||
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 0f)).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* The arithmetic behind the pinch's focal anchor (#56) — the one part of the
|
||||
* gesture that is not a pointer-event concern and can be pinned down here.
|
||||
*/
|
||||
class TimelineZoomTest {
|
||||
|
||||
@Test
|
||||
fun `the moment under the fingers stays under them`() {
|
||||
// Scrolled to 09:00 at 56dp/h, pinching around a point 100px down the
|
||||
// viewport — that point is 10:47-ish, and it has to still be there after.
|
||||
val old = 56f
|
||||
val new = 84f
|
||||
val scroll = (9 * old).toInt()
|
||||
val centroidY = 100f
|
||||
val before = (scroll + centroidY) / old
|
||||
|
||||
val after = anchoredScroll(scroll, centroidY, old, new)
|
||||
|
||||
assertThat((after + centroidY) / new).isWithin(0.001f).of(before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zooming in scrolls down and zooming out scrolls back up`() {
|
||||
val scroll = 500
|
||||
val centroidY = 200f
|
||||
assertThat(anchoredScroll(scroll, centroidY, 56f, 84f)).isGreaterThan(scroll.toFloat())
|
||||
assertThat(anchoredScroll(scroll, centroidY, 56f, 32f)).isLessThan(scroll.toFloat())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unchanged scale asks for no scroll`() {
|
||||
assertThat(anchoredScroll(500, 200f, 56f, 56f)).isWithin(0.001f).of(500f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinching at the very top of a day held at the top keeps it there`() {
|
||||
// Midnight is at offset 0 whatever the scale, so there is nothing to
|
||||
// correct — a pinch here must not push the day off its own start.
|
||||
assertThat(anchoredScroll(0, 0f, 56f, 84f)).isWithin(0.001f).of(0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch lands on whole pixels`() {
|
||||
// The gutter is 24 stacked hour-tall boxes, each rounding its own height,
|
||||
// while the lines and blocks are drawn at the fractional one — a
|
||||
// fractional hour puts the two on different grids and jumps the labels
|
||||
// about as the pinch drifts across each half pixel.
|
||||
for (target in listOf(56.4f, 56.6f, 83.5f, 120.01f)) {
|
||||
val landed = pinchedHourHeightPx(target, fillPx = 28f, maxPx = 240f)
|
||||
assertThat(landed).isEqualTo(landed.toInt().toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch stops where the day fills the screen`() {
|
||||
assertThat(pinchedHourHeightPx(target = 5f, fillPx = 28f, maxPx = 240f)).isEqualTo(28f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filling the screen outranks the ceiling`() {
|
||||
// On a viewport tall enough for the two to disagree, dead space under
|
||||
// midnight is the worse of the two failures.
|
||||
assertThat(pinchedHourHeightPx(target = 10f, fillPx = 300f, maxPx = 240f)).isEqualTo(300f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch stops at the ceiling`() {
|
||||
assertThat(pinchedHourHeightPx(target = 9_000f, fillPx = 28f, maxPx = 240f)).isEqualTo(240f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch held against a fractional bound stays put`() {
|
||||
// A bound that is not a whole pixel is a height the pinch can be pushed
|
||||
// against but never land on, so every frame of a held gesture would look
|
||||
// like a scale change and hand the focal anchor a scroll correction.
|
||||
val fillPx = 62.083f
|
||||
val maxPx = 616.5f
|
||||
|
||||
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
||||
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
||||
|
||||
assertThat(floor).isEqualTo(63f)
|
||||
assertThat(ceiling).isEqualTo(616f)
|
||||
// Landing there and being pushed further must not move them again.
|
||||
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
||||
assertThat(pinchedHourHeightPx(ceiling * 1.1f, fillPx, maxPx)).isEqualTo(ceiling)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fill floor never leaves dead space under midnight`() {
|
||||
// Rounding the floor down would open a gap the pinch cannot close.
|
||||
val viewport = 1490f
|
||||
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
|
||||
assertThat(floor * 24).isAtLeast(viewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settled pinch is what gets persisted`() {
|
||||
var persisted: TimelineScale? = null
|
||||
val zoom = TimelineZoom(TimelineScale.Regular) { persisted = it }
|
||||
|
||||
zoom.beginPinch()
|
||||
zoom.pinchTo(70.dp)
|
||||
assertThat(zoom.scale).isEqualTo(TimelineScale.custom(70.dp))
|
||||
// Nothing is written until the fingers lift — a DataStore write per
|
||||
// pointer frame is what this state holder exists to avoid.
|
||||
assertThat(persisted).isNull()
|
||||
|
||||
zoom.endPinch()
|
||||
assertThat(persisted).isEqualTo(TimelineScale.custom(70.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stored value cannot snap the timeline back mid-pinch`() {
|
||||
val zoom = TimelineZoom(TimelineScale.Regular) {}
|
||||
zoom.beginPinch()
|
||||
zoom.pinchTo(70.dp)
|
||||
|
||||
// The preference echoing its old value back (it is a frame or two behind
|
||||
// the fingers) must not land while the gesture is still running.
|
||||
zoom.adopt(TimelineScale.Regular)
|
||||
assertThat(zoom.scale).isEqualTo(TimelineScale.custom(70.dp))
|
||||
|
||||
// Once it has settled, Settings can still move it.
|
||||
zoom.endPinch()
|
||||
zoom.adopt(TimelineScale.Compact)
|
||||
assertThat(zoom.scale).isEqualTo(TimelineScale.Compact)
|
||||
}
|
||||
}
|
||||
@@ -33,27 +33,15 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
||||
the F-Droid per-version changelog.
|
||||
3. Bump the committed `versionName` (and `versionCode`) in
|
||||
`app/build.gradle.kts` to the new version. **This bump is what triggers the
|
||||
release** when the branch merges to `main`. Then write the per-version
|
||||
"What's New" by hand to
|
||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it.
|
||||
|
||||
**Keep it under 500 characters.** That one file is what both the **official**
|
||||
F-Droid repo (which reads it from the tagged source tree) and **Google Play**
|
||||
publish, and Play caps "What's New" at 500 characters while F-Droid truncates
|
||||
long entries in-client. So it is a short summary — a handful of bullets
|
||||
naming the headline changes — not a copy of the CHANGELOG.md section, which
|
||||
runs to thousands of characters. `CHANGELOG.md` stays the full account and is
|
||||
what the Gitea/Codeberg release notes use.
|
||||
|
||||
Then run
|
||||
release** when the branch merges to `main`. Then run
|
||||
```bash
|
||||
scripts/sync_changelog_to_fastlane.sh
|
||||
```
|
||||
to check it. The script **keeps** a committed file untouched and only warns
|
||||
if it is over the limit; it extracts the CHANGELOG.md section as a fallback
|
||||
solely when the file is missing, so the self-hosted pipeline always has
|
||||
something to publish. The pipeline runs the same script, so forgetting to
|
||||
write the file yields a long auto-generated changelog rather than none.
|
||||
and commit the generated
|
||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`. This is what
|
||||
makes the **official** F-Droid repo show this version's changelog (it reads
|
||||
the changelog from the tagged source tree). The self-hosted pipeline
|
||||
regenerates it regardless, so forgetting only affects the official listing.
|
||||
4. **Verify the release build on a real device** — the mandatory gate. The
|
||||
shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or
|
||||
only on first run, never show up in the debug build or on a device that
|
||||
@@ -154,9 +142,8 @@ because the store listing already lives in `fastlane/metadata/android/` — the
|
||||
same tree the official F-Droid repo harvests. One metadata source, two stores.
|
||||
|
||||
**What gets uploaded per release:** the AAB, plus the per-version "What's New"
|
||||
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — the
|
||||
hand-written summary from step 3, which is why it must stay **under 500
|
||||
characters**: Play rejects a longer one. Listing text is
|
||||
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (generated
|
||||
from `CHANGELOG.md` by `scripts/sync_changelog_to_fastlane.sh`). Listing text is
|
||||
**not** touched — an accidental overwrite of a live listing triggers a Play
|
||||
policy review. Sync it deliberately with `bundle exec fastlane listing`.
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
إصلاحات
|
||||
• عرض الأسبوع واليوم: النقر على مساحة فارغة يُنشئ الحدث في الساعة التي نقرت عليها، حتى بعد تكبير شريط الوقت أو تغيير ارتفاع الساعة.
|
||||
@@ -1,2 +0,0 @@
|
||||
Behoben
|
||||
• Wochen- und Tagesansicht: Ein Tippen auf einen freien Bereich legt den Termin jetzt zur angetippten Uhrzeit an – auch nachdem die Zeitleiste gezoomt oder die Stundenhöhe geändert wurde.
|
||||
@@ -1,2 +0,0 @@
|
||||
Fixed
|
||||
• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height.
|
||||
@@ -1,12 +0,0 @@
|
||||
New
|
||||
• Default event duration — how long a new event opens, per calendar.
|
||||
• Week & day: choose an hour height, or pinch to zoom. "Fit whole day" shows all 24 hours at once.
|
||||
• Tap the date in the top bar to jump to another day.
|
||||
|
||||
Changed
|
||||
• Calendar colours reworked so event text is always readable.
|
||||
• Reminders now carry Calendula's own status-bar icon.
|
||||
|
||||
Fixed
|
||||
• Back closes the sidebar instead of the app, and its rows line up.
|
||||
• Status-bar icons follow the app's light/dark choice.
|
||||
@@ -1,2 +0,0 @@
|
||||
Fixed
|
||||
• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height.
|
||||
@@ -1,2 +0,0 @@
|
||||
Corregido
|
||||
• Vista de semana y día: al tocar un hueco libre, el evento se crea a la hora que has tocado, incluso después de ampliar la línea de tiempo o cambiar la altura de la hora.
|
||||
@@ -1,2 +0,0 @@
|
||||
Corrigé
|
||||
• Vues Semaine et Jour : appuyer sur un créneau vide crée désormais l'événement à l'heure touchée, même après avoir zoomé sur la grille ou modifié la hauteur d'une heure.
|
||||
@@ -1,2 +0,0 @@
|
||||
Corretto
|
||||
• Vista settimana e giorno: toccando uno spazio libero l'evento viene creato all'ora toccata, anche dopo aver ingrandito la linea temporale o cambiato l'altezza dell'ora.
|
||||
@@ -1,2 +0,0 @@
|
||||
Poprawki
|
||||
• Widok tygodnia i dnia: dotknięcie pustego miejsca tworzy teraz wydarzenie o dotkniętej godzinie, także po powiększeniu osi czasu lub zmianie wysokości godziny.
|
||||
@@ -1,2 +0,0 @@
|
||||
Corrigido
|
||||
• Vista de semana e de dia: tocar num espaço livre cria o evento à hora tocada, mesmo depois de ampliar a linha temporal ou alterar a altura da hora.
|
||||
@@ -1,2 +0,0 @@
|
||||
Исправлено
|
||||
• Вид недели и дня: нажатие на свободное место создаёт событие на выбранный час — в том числе после масштабирования шкалы времени или изменения высоты часа.
|
||||
@@ -1,2 +0,0 @@
|
||||
修复
|
||||
• 周视图和日视图:点按空白处会在所点的时间创建事件,即使在缩放时间轴或更改小时高度之后也是如此。
|
||||
@@ -22,7 +22,7 @@ kotlinxDatetime = "0.7.0"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
turbine = "1.2.1"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
lifecycleCompose = "2.11.0"
|
||||
lifecycleCompose = "2.10.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||
glance = "1.1.1"
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ensure the current version's "What's New" exists at
|
||||
# fastlane/metadata/android/en-US/changelogs/<code>.txt — the one file both the
|
||||
# official F-Droid repo and Google Play read (en-US is F-Droid's fallback
|
||||
# locale, so it covers every language).
|
||||
# Write the current version's CHANGELOG.md section into the fastlane changelog
|
||||
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
|
||||
# (en-US is F-Droid's fallback locale, so it covers every language).
|
||||
#
|
||||
# A COMMITTED file wins and is never rewritten. Play caps "What's New" at 500
|
||||
# characters and F-Droid truncates long entries in-client, so this file is a
|
||||
# hand-written summary, not a copy of the CHANGELOG.md section — those run to
|
||||
# thousands of characters. Write it when cutting a release, keep it under 500,
|
||||
# and commit it.
|
||||
# Run this when cutting a release (after editing CHANGELOG.md and bumping
|
||||
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
|
||||
# F-Droid repo — which reads the changelog from the tagged source tree — shows
|
||||
# this version's "What's New". The self-hosted release pipeline also runs it so
|
||||
# its changelog never depends on the file having been committed. Idempotent.
|
||||
#
|
||||
# Only when the file is missing does this fall back to extracting the
|
||||
# CHANGELOG.md section, so the self-hosted release pipeline always has
|
||||
# something to publish. The extraction matches the awk used for the Gitea
|
||||
# release notes.
|
||||
# Extraction matches the awk used for the Gitea release notes so all three
|
||||
# (release notes, self-hosted changelog, official changelog) stay in sync.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
LIMIT=500
|
||||
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
|
||||
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
|
||||
@@ -29,22 +24,18 @@ CL_DIR="fastlane/metadata/android/en-US/changelogs"
|
||||
mkdir -p "$CL_DIR"
|
||||
OUT="$CL_DIR/${VERSION_CODE}.txt"
|
||||
|
||||
if [ -s "$OUT" ]; then
|
||||
ACTION="Kept"
|
||||
else
|
||||
ACTION="Generated"
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > "$OUT"
|
||||
# Trim leading blank lines (same as the pipeline did).
|
||||
sed -i -e '/./,$!d' "$OUT"
|
||||
[ -s "$OUT" ] || echo "See CHANGELOG.md for $VERSION." > "$OUT"
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > "$OUT"
|
||||
# Trim leading blank lines (same as the pipeline did).
|
||||
sed -i -e '/./,$!d' "$OUT"
|
||||
if [ ! -s "$OUT" ]; then
|
||||
echo "See CHANGELOG.md for $VERSION." > "$OUT"
|
||||
fi
|
||||
|
||||
CHARS=$(wc -m < "$OUT" | tr -d ' ')
|
||||
echo "$ACTION $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
|
||||
if [ "$CHARS" -gt "$LIMIT" ]; then
|
||||
echo " warning: >${LIMIT} chars — Play rejects this and F-Droid truncates it." >&2
|
||||
echo " Replace $OUT with a hand-written summary under ${LIMIT} chars." >&2
|
||||
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
|
||||
if [ "$CHARS" -gt 500 ]; then
|
||||
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user