Compare commits

..

3 Commits

Author SHA1 Message Date
7c1a03eac1 Settle the pinch at its clamp boundaries (#56)
Whole-pixel clamp bounds so a held pinch stops nudging the scroll, and
endPinch() in a finally so a cancelled gesture can't deafen the zoom to
Settings.
2026-07-31 20:43:00 +02:00
5bdc4a20b0 Fix the pinch's jumpy hour gutter and its zoom-out floor (#56)
Both found reviewing the gesture on device.

The gutter is 24 stacked hour-tall boxes, each rounding its own height to
whole pixels, while the hour lines and event blocks are drawn at the
fractional height. A pinch lands on fractional heights constantly, so the
two sat on different grids — at 56.4px per hour the 23:00 label is ~9px
off the line it names — and the whole label column jumped as the pinch
drifted across each half pixel. A pinch now lands on whole pixels, so
every part of the timeline shares one grid.

Zooming out stopped at a fixed 24dp per hour, which is below the height
that fills the screen on a phone, so you could shrink the day until it
left dead space under midnight. The floor is now the height at which all
24 hours exactly fill the viewport: past that there is no more day to
uncover. It is applied when the height is resolved rather than only in
the gesture, so a height pinched in landscape still fills portrait.
2026-07-31 20:14:36 +02:00
f84164bb0c Add pinch-to-zoom to the week and day timeline (#56)
The issue asked for a pinch gesture; part A only shipped the settings
presets, so this adds the gesture itself. Two fingers on the timeline
rescale the hour height continuously, and the moment under the fingers
stays under them while it zooms, so you keep your place in the day.

The scale preference grows a Custom case to hold what a pinch settles
on — a direct manipulation should be able to land between the named
steps rather than snapping to one. It is stored as "custom:<dp>"; the
preset names are unchanged, so an existing choice reads back as it was.
The picker lists Custom alongside the presets while it is in force, so
it is visible and one tap gets you back to a named step.

Three gestures share the timeline, so the pinch only claims it once a
second finger is down and the spread has passed a slop threshold —
one-finger scrolling, the week swipe and a two-finger scroll are all
untouched. It watches the initial pointer pass, since the vertical
scroll it has to outrank is a descendant.

The presets stay the accessible route to the same setting; a pinch is
not reachable without two-finger input.
2026-07-31 20:00:31 +02:00
12 changed files with 594 additions and 27 deletions

View File

@@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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
- Event blocks now only draw text they can draw whole. One too short for a full

View File

@@ -29,7 +29,8 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
import de.jeanlucmakiola.calendula.ui.common.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
@@ -126,6 +127,13 @@ 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(
@@ -142,7 +150,7 @@ class MainActivity : AppCompatActivity() {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
LocalTimelineScale provides settings.timelineScale,
LocalTimelineZoom provides timelineZoom,
LocalSoftenColors provides settings.softenColors,
) {
RootScreen(

View File

@@ -22,6 +22,8 @@ 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,14 +275,15 @@ class SettingsPrefs @Inject constructor(
/**
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
* [TimelineScale.Regular] — the historical 56dp scale.
* [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 ->
prefs[TIMELINE_SCALE_KEY].toEnum(TimelineScale.Regular)
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
}
suspend fun setTimelineScale(scale: TimelineScale) {
store.edit { it[TIMELINE_SCALE_KEY] = scale.name }
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
}
/**

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
@@ -16,23 +15,42 @@ import de.jeanlucmakiola.calendula.R
* 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.
*/
enum class TimelineScale {
sealed interface TimelineScale {
/** Whole day in one screen: the hour height follows the viewport. */
FitDay,
data object FitDay : TimelineScale
/** Denser than the default, still a fixed height. */
Compact,
data object Compact : TimelineScale
/** The historical 56dp scale. */
Regular,
data object Regular : TimelineScale
/** Roomier blocks, more scrolling. */
Comfortable,
}
data object Comfortable : TimelineScale
/** The scale the timelines draw at, from the `timelineScale` preference. */
val LocalTimelineScale = staticCompositionLocalOf { TimelineScale.Regular }
/** 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
@@ -42,12 +60,20 @@ val LocalTimelineScale = staticCompositionLocalOf { TimelineScale.Regular }
* 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))
}
/**
@@ -75,6 +101,30 @@ 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) {
@@ -82,6 +132,7 @@ val TimelineScale.labelRes: Int
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
@@ -91,4 +142,50 @@ val TimelineScale.descriptionRes: Int
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
}

View File

@@ -0,0 +1,230 @@
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

View File

@@ -87,10 +87,11 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
import de.jeanlucmakiola.calendula.ui.common.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
@@ -487,19 +488,23 @@ private fun Timeline(
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val scale = LocalTimelineScale.current
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 = scale.hourHeight(maxHeight)
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)
// 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()) {
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// 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(
@@ -680,7 +685,7 @@ private fun EventBlock(
@Composable
private fun DayLoading() {
val scale = LocalTimelineScale.current
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column

View File

@@ -335,7 +335,13 @@ internal fun ViewsScreen(
title = stringResource(R.string.settings_timeline_scale),
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
predictiveBack = true,
options = TimelineScale.entries,
// 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) },

View File

@@ -97,11 +97,12 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
import de.jeanlucmakiola.calendula.ui.common.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
@@ -622,20 +623,24 @@ private fun Timeline(
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val scale = LocalTimelineScale.current
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 = scale.hourHeight(maxHeight)
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)
// 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()) {
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// 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(
@@ -845,7 +850,7 @@ private fun EventBlock(
@Composable
private fun WeekLoading() {
val scale = LocalTimelineScale.current
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
Column(modifier = Modifier.fillMaxSize()) {
// Header skeleton

View File

@@ -364,7 +364,7 @@
<string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<string name="settings_timeline_scale">Hour height</string>
<string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting.</string>
<string name="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>
@@ -373,6 +373,8 @@
<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>

View File

@@ -5,6 +5,7 @@ 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
@@ -109,6 +110,21 @@ class SettingsPrefsTest {
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))

View File

@@ -66,9 +66,64 @@ class TimelineScaleTest {
fun `the minimum event height stays a fixed share of an hour`() {
// A fixed dp floor would swallow ever more of the day as the scale drops;
// as a fraction it always means the same duration.
for (scale in TimelineScale.entries) {
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)
}
}
}

View File

@@ -0,0 +1,135 @@
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)
}
}