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.
This commit is contained in:
@@ -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
|
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
|
either side of the previous spacing, which stays the default. Both views share
|
||||||
the setting ([#56]).
|
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
|
### Changed
|
||||||
- Event blocks now only draw text they can draw whole. One too short for a full
|
- Event blocks now only draw text they can draw whole. One too short for a full
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
@@ -126,6 +127,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// re-import stamp AppFontSettings carries, so replacing the file
|
// re-import stamp AppFontSettings carries, so replacing the file
|
||||||
// behind an active "custom" token still refreshes — changes;
|
// behind an active "custom" token still refreshes — changes;
|
||||||
// "system for both" returns the default scale untouched.
|
// "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 fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
val typography = remember(fonts, context) {
|
val typography = remember(fonts, context) {
|
||||||
calendulaTypography(
|
calendulaTypography(
|
||||||
@@ -142,7 +150,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
LocalTimelineScale provides settings.timelineScale,
|
LocalTimelineZoom provides timelineZoom,
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
|
|||||||
@@ -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.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.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.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
@@ -273,14 +275,15 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
|
* 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 ->
|
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) {
|
suspend fun setTimelineScale(scale: TimelineScale) {
|
||||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.name }
|
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -16,23 +15,38 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
* phone the default scale shows about half a day, so a whole week can hide
|
* 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
|
* appointments below the fold. It derives the hour height from the timeline's
|
||||||
* own viewport instead of a fixed value.
|
* 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. */
|
/** Whole day in one screen: the hour height follows the viewport. */
|
||||||
FitDay,
|
data object FitDay : TimelineScale
|
||||||
|
|
||||||
/** Denser than the default, still a fixed height. */
|
/** Denser than the default, still a fixed height. */
|
||||||
Compact,
|
data object Compact : TimelineScale
|
||||||
|
|
||||||
/** The historical 56dp scale. */
|
/** The historical 56dp scale. */
|
||||||
Regular,
|
data object Regular : TimelineScale
|
||||||
|
|
||||||
/** Roomier blocks, more scrolling. */
|
/** Roomier blocks, more scrolling. */
|
||||||
Comfortable,
|
data object Comfortable : TimelineScale
|
||||||
}
|
|
||||||
|
|
||||||
/** The scale the timelines draw at, from the `timelineScale` preference. */
|
/** A height the user pinched to. Build it through [custom], which clamps. */
|
||||||
val LocalTimelineScale = staticCompositionLocalOf { TimelineScale.Regular }
|
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, clamped into the legible zoom range. */
|
||||||
|
fun custom(hourHeight: Dp): Custom =
|
||||||
|
Custom(hourHeight.coerceIn(MIN_PINCH_HOUR_HEIGHT, MAX_PINCH_HOUR_HEIGHT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hour height for this scale. [viewportHeight] is the visible height of the
|
* Hour height for this scale. [viewportHeight] is the visible height of the
|
||||||
@@ -48,6 +62,7 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
|||||||
TimelineScale.Compact -> 40.dp
|
TimelineScale.Compact -> 40.dp
|
||||||
TimelineScale.Regular -> 56.dp
|
TimelineScale.Regular -> 56.dp
|
||||||
TimelineScale.Comfortable -> 80.dp
|
TimelineScale.Comfortable -> 80.dp
|
||||||
|
is TimelineScale.Custom -> this.hourHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +90,19 @@ val FIT_DAY_MIN = 24.dp
|
|||||||
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
|
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
val FIT_DAY_MAX = 96.dp
|
val FIT_DAY_MAX = 96.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor for a pinch — the same legibility limit [TimelineScale.FitDay] stops at,
|
||||||
|
* since the gutter has the same 24 labels to fit either way.
|
||||||
|
*/
|
||||||
|
val MIN_PINCH_HOUR_HEIGHT = FIT_DAY_MIN
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val TimelineScale.labelRes: Int
|
val TimelineScale.labelRes: Int
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
@@ -82,6 +110,7 @@ val TimelineScale.labelRes: Int
|
|||||||
TimelineScale.Compact -> R.string.timeline_scale_compact
|
TimelineScale.Compact -> R.string.timeline_scale_compact
|
||||||
TimelineScale.Regular -> R.string.timeline_scale_regular
|
TimelineScale.Regular -> R.string.timeline_scale_regular
|
||||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||||
|
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
@@ -91,4 +120,40 @@ val TimelineScale.descriptionRes: Int
|
|||||||
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
|
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
|
||||||
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
||||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_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]. */
|
||||||
|
fun TimelineScale.storageValue(): String = when (this) {
|
||||||
|
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||||
|
else -> 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelinePinchZoom(
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourHeight: Dp,
|
||||||
|
zoom: TimelineZoom,
|
||||||
|
): Modifier {
|
||||||
|
// The gesture loop outlives any single composition, so it reads the height
|
||||||
|
// through a state handle rather than capturing the value it started with.
|
||||||
|
val currentHourHeight = rememberUpdatedState(hourHeight)
|
||||||
|
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
|
||||||
|
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 = (old * step)
|
||||||
|
.coerceIn(MIN_PINCH_HOUR_HEIGHT.toPx(), MAX_PINCH_HOUR_HEIGHT.toPx())
|
||||||
|
if (new != old) {
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
if (claimed) zoom.endPinch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
@@ -87,10 +87,11 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
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.formatHourLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
@@ -487,19 +488,23 @@ private fun Timeline(
|
|||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val scale = LocalTimelineScale.current
|
val zoom = LocalTimelineZoom.current
|
||||||
|
|
||||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
// 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
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
// bar, date header and all-day strip.
|
// bar, date header and all-day strip.
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
val hourHeight = scale.hourHeight(maxHeight)
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
val totalHeight = hourHeight * 24
|
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, hourHeight, zoom)
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position.
|
// 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
|
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||||
// labels centre on the top bar hamburger, matching the week view.
|
// labels centre on the top bar hamburger, matching the week view.
|
||||||
Column(
|
Column(
|
||||||
@@ -680,7 +685,7 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayLoading() {
|
private fun DayLoading() {
|
||||||
val scale = LocalTimelineScale.current
|
val scale = LocalTimelineZoom.current.scale
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||||
|
|||||||
@@ -335,7 +335,13 @@ internal fun ViewsScreen(
|
|||||||
title = stringResource(R.string.settings_timeline_scale),
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
|
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
|
||||||
predictiveBack = true,
|
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,
|
selected = state.timelineScale,
|
||||||
label = { stringResource(it.labelRes) },
|
label = { stringResource(it.labelRes) },
|
||||||
summary = { stringResource(it.descriptionRes) },
|
summary = { stringResource(it.descriptionRes) },
|
||||||
|
|||||||
@@ -97,11 +97,12 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
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.MIN_TITLE_WRAP_WIDTH
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
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.formatHourLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
@@ -622,20 +623,24 @@ private fun Timeline(
|
|||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val scale = LocalTimelineScale.current
|
val zoom = LocalTimelineZoom.current
|
||||||
|
|
||||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
// 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
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
// bar, day header and all-day strip.
|
// bar, day header and all-day strip.
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
val hourHeight = scale.hourHeight(maxHeight)
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
val totalHeight = hourHeight * 24
|
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, hourHeight, zoom)
|
||||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position (not just at the
|
// soft corners are permanent at any scroll position (not just at the
|
||||||
// day's start/end).
|
// 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
|
// 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.
|
// as the header badge so the labels sit under it and on the hamburger.
|
||||||
Column(
|
Column(
|
||||||
@@ -845,7 +850,7 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekLoading() {
|
private fun WeekLoading() {
|
||||||
val scale = LocalTimelineScale.current
|
val scale = LocalTimelineZoom.current.scale
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header skeleton
|
// Header skeleton
|
||||||
|
|||||||
@@ -364,7 +364,7 @@
|
|||||||
<string name="settings_hour_lines">Hour lines</string>
|
<string name="settings_hour_lines">Hour lines</string>
|
||||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||||
<string name="settings_timeline_scale">Hour height</string>
|
<string name="settings_timeline_scale">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">Fit whole day</string>
|
||||||
<string name="timeline_scale_fit_day_summary">All 24 hours on one screen, no scrolling</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">Compact</string>
|
||||||
@@ -373,6 +373,8 @@
|
|||||||
<string name="timeline_scale_regular_summary">The standard spacing</string>
|
<string name="timeline_scale_regular_summary">The standard spacing</string>
|
||||||
<string name="timeline_scale_comfortable">Comfortable</string>
|
<string name="timeline_scale_comfortable">Comfortable</string>
|
||||||
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</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">Dim completed events</string>
|
||||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||||
<string name="settings_past_events">Past events</string>
|
<string name="settings_past_events">Past events</string>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
@@ -109,6 +110,21 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.timelineScale.first()).isEqualTo(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
|
@Test
|
||||||
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
|||||||
@@ -66,9 +66,57 @@ class TimelineScaleTest {
|
|||||||
fun `the minimum event height stays a fixed share of an hour`() {
|
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;
|
// A fixed dp floor would swallow ever more of the day as the scale drops;
|
||||||
// as a fraction it always means the same duration.
|
// as a fraction it always means the same duration.
|
||||||
for (scale in TimelineScale.entries) {
|
for (scale in TimelineScale.presets) {
|
||||||
val hour = scale.hourHeight(phoneViewport)
|
val hour = scale.hourHeight(phoneViewport)
|
||||||
assertThat((hour * MIN_EVENT_FRACTION) / hour).isWithin(0.001f).of(MIN_EVENT_FRACTION)
|
assertThat((hour * MIN_EVENT_FRACTION) / hour).isWithin(0.001f).of(MIN_EVENT_FRACTION)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinched height is used as given, whatever the viewport`() {
|
||||||
|
val scale = TimelineScale.custom(63.dp)
|
||||||
|
assertThat(scale.hourHeight(phoneViewport)).isEqualTo(63.dp)
|
||||||
|
assertThat(scale.hourHeight(120.dp)).isEqualTo(63.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinch cannot zoom past the legible range`() {
|
||||||
|
// The gesture clamps as it goes, but the floor and ceiling belong to the
|
||||||
|
// model — nothing that constructs a scale may land outside them (#56).
|
||||||
|
assertThat(TimelineScale.custom(1.dp).hourHeight).isEqualTo(MIN_PINCH_HOUR_HEIGHT)
|
||||||
|
assertThat(TimelineScale.custom(10_000.dp).hourHeight).isEqualTo(MAX_PINCH_HOUR_HEIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinch can go beyond every preset in both directions`() {
|
||||||
|
// 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(MIN_PINCH_HOUR_HEIGHT).isAtMost(presetHeights.min())
|
||||||
|
assertThat(MAX_PINCH_HOUR_HEIGHT).isGreaterThan(presetHeights.max())
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user