diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e5eeb..6045fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Tapping an empty slot in week or day view creates the event at the hour you + tapped, after the timeline has been zoomed. Pinching the day taller or shorter + — or changing **Hour height** in Settings — left the tap still being measured + against the old spacing, so the new event landed at some other hour entirely + ([#148]). + ## [2.18.0] — 2026-07-31 ### Added @@ -1323,3 +1330,4 @@ automatically, with zero telemetry and no internet permission. [#57]: https://codeberg.org/jlmakiola/calendula/issues/57 [#56]: https://codeberg.org/jlmakiola/calendula/issues/56 [#114]: https://codeberg.org/jlmakiola/calendula/issues/114 +[#148]: https://codeberg.org/jlmakiola/calendula/issues/148 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 d514029..b236aef 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 @@ -76,6 +76,18 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { .coerceAtLeast(fillHourHeight(viewportHeight)) } +/** + * The minute of the day a tap [offsetY] px down a timeline column means, snapped + * to the hour it landed in. + * + * [hourPx] must be the hour height the column is drawing at *now* — a tap + * detector that captured it when it was installed maps taps to a pre-pinch grid + * (#148). A zero or negative height (a viewport measured at nothing) has no grid + * to read, so it answers midnight rather than dividing by it. + */ +fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int = + if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60 + /** * Shortest an event block may render, as a fraction of an hour. Blocks keep a * floor so a 15-minute event stays tappable, but the floor scales with the hour 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 1027d13..a03b5fd 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 @@ -45,6 +45,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -96,6 +97,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines +import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.week.TimedBlock import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -577,6 +579,12 @@ private fun DayColumnCard( val hourPx = with(LocalDensity.current) { hourHeight.toPx() } val showHourLines = LocalShowHourLines.current val hourLineColor = MaterialTheme.colorScheme.outlineVariant + // The tap detector outlives the composition that installed it — a pinch or a + // Settings change moves the hour height without restarting it — so it reads + // the height and the callback through state handles instead of capturing + // them, or taps land on the scale the column had before the zoom (#148). + val currentHourPx = rememberUpdatedState(hourPx) + val currentOnCreateAt = rememberUpdatedState(onCreateAt) Card( // Plain rectangular column — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -597,8 +605,10 @@ private fun DayColumnCard( // only fires on the column background. Snaps to the tapped hour. .pointerInput(date) { detectTapGestures { offset -> - val hour = (offset.y / hourPx).toInt().coerceIn(0, 23) - onCreateAt(date, hour * 60) + currentOnCreateAt.value( + date, + tappedMinuteOfDay(offset.y, currentHourPx.value), + ) } }, ) { 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 f4ff867..306bda5 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 @@ -51,6 +51,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -107,6 +108,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines +import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber @@ -722,6 +724,12 @@ private fun DayColumnCard( val hourPx = with(LocalDensity.current) { hourHeight.toPx() } val showHourLines = LocalShowHourLines.current val hourLineColor = MaterialTheme.colorScheme.outlineVariant + // The tap detector outlives the composition that installed it — a pinch or a + // Settings change moves the hour height without restarting it — so it reads + // the height and the callback through state handles instead of capturing + // them, or taps land on the scale the column had before the zoom (#148). + val currentHourPx = rememberUpdatedState(hourPx) + val currentOnCreateAt = rememberUpdatedState(onCreateAt) Card( // Plain rectangular columns — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -741,8 +749,10 @@ private fun DayColumnCard( // blocks are consumed by their own handler first. Snaps to hour. .pointerInput(date) { detectTapGestures { offset -> - val hour = (offset.y / hourPx).toInt().coerceIn(0, 23) - onCreateAt(date, hour * 60) + currentOnCreateAt.value( + date, + tappedMinuteOfDay(offset.y, currentHourPx.value), + ) } }, ) { 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 c78bcd4..15b1e19 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 @@ -126,4 +126,24 @@ class TimelineScaleTest { assertThat(parseTimelineScale(stored)).isEqualTo(TimelineScale.Regular) } } + + @Test + fun `a tap creates at the hour it landed in, at whatever scale`() { + // Same point on the same column reads as a different hour once the + // timeline has been pinched — which is the whole of #148: the tap has to + // be measured against the height the column is drawing at now. + assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 100f)).isEqualTo(5 * 60) + assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 50f)).isEqualTo(10 * 60) + // Within an hour it snaps back to that hour's start. + assertThat(tappedMinuteOfDay(offsetY = 599f, hourPx = 100f)).isEqualTo(5 * 60) + } + + @Test + fun `a tap outside the day stays inside it`() { + assertThat(tappedMinuteOfDay(offsetY = -20f, hourPx = 56f)).isEqualTo(0) + assertThat(tappedMinuteOfDay(offsetY = 99_999f, hourPx = 56f)).isEqualTo(23 * 60) + // A viewport measured at nothing has no grid to read — midnight, not a + // division by zero. + assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 0f)).isEqualTo(0) + } }