diff --git a/CHANGELOG.md b/CHANGELOG.md index 419b622..4bf0f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index ebb8af6..85f94ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index b5ceced..2713f3c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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() } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt index 958025b..a8133b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt @@ -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,38 @@ 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 = 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 @@ -48,6 +62,7 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { TimelineScale.Compact -> 40.dp TimelineScale.Regular -> 56.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. */ 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 val TimelineScale.labelRes: Int get() = when (this) { @@ -82,6 +110,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 +120,40 @@ 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 = 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt new file mode 100644 index 0000000..2882298 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 0a51e2c..07558b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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, 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index fd1dc62..18bd68f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -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) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index caa259a..c53fdee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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, 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a830a56..621ca08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -364,7 +364,7 @@ Hour lines Show a separator line at each hour in week and day view Hour height - How much vertical space one hour takes in week and day view. Both views share this setting. + 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. Fit whole day All 24 hours on one screen, no scrolling Compact @@ -373,6 +373,8 @@ The standard spacing Comfortable Roomier blocks, more scrolling + Custom + The height you pinched the timeline to Dim completed events Fade events that have already ended in month and week view Past events diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 05f97b9..c8754c9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt index cd394e6..d1bcd67 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt @@ -66,9 +66,57 @@ 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, 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) + } + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoomTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoomTest.kt new file mode 100644 index 0000000..087d1a3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoomTest.kt @@ -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) + } +}