Show the drag time in the hour gutter (#68)

While a block is lifted the gutter dims its hour labels and floats a badge
with the drop's start time, so the target reads off the same scale the
labels do. Gutter extracted into a shared composable — day and week had
identical copies.
This commit is contained in:
2026-08-02 17:43:36 +02:00
parent 0bcbd2751c
commit 19b86936e6
5 changed files with 157 additions and 68 deletions

View File

@@ -0,0 +1,121 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.locale.currentLocale
/** Width of the hour gutter down the start edge of the day and week timelines. */
val GUTTER_WIDTH = 48.dp
/**
* Start inset for the gutter's content (week badge + hour labels) so it centres
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
* matching the icon button's centre.
*/
val GUTTER_CONTENT_START_INSET = 8.dp
private val BADGE_HEIGHT = 20.dp
/** How far the fixed hour labels recede while a block is being dragged. */
private const val DIMMED_HOUR_ALPHA = 0.3f
/**
* The timeline's hour gutter. Scrolls in sync with the day columns through the
* shared [scrollState], and while [dragController] holds a lifted block it dims
* the hour labels and floats a badge with the drag's current start time at the
* row the block would land on.
*/
@Composable
fun HourGutter(
scrollState: ScrollState,
hourHeight: Dp,
dragController: TimelineDragController,
modifier: Modifier = Modifier,
) {
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
// Derived, not read directly: the drag is rewritten every frame, while its
// snapped start only changes once per slot — which is all the gutter shows.
val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin }
}
val hourAlpha by animateFloatAsState(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
label = "hourLabelAlpha",
)
Box(
modifier = modifier
.width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
Column {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(hourHeight),
) {
if (h > 0) {
Text(
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = hourAlpha),
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
dragStartMin?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = top)
.height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp)
// The dragged block behind it already carries this time; a
// second copy would only duplicate the announcement.
.clearAndSetSemantics { },
contentAlignment = Alignment.Center,
) {
Text(
text = formatGutterTime(startMin, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1,
softWrap = false,
)
}
}
}
}

View File

@@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
private const val PATTERN_24 = "HH:mm" private const val PATTERN_24 = "HH:mm"
private const val PATTERN_12 = "h:mm a" private const val PATTERN_12 = "h:mm a"
private const val HOUR_PATTERN_12 = "h a" private const val HOUR_PATTERN_12 = "h a"
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */ /** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter = fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
@@ -42,6 +43,18 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale) else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
} }
/**
* The time shown in the timeline gutter while a block is dragged: 24h →
* "09:15", 12h → "9:15". The meridiem is dropped on purpose — the hour labels
* around it already carry it, and the gutter is too narrow to hold it.
*/
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
return LocalTime.of(clamped / 60, clamped % 60)
.format(DateTimeFormatter.ofPattern(pattern, locale))
}
/** /**
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded, * The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
* the prior look); 12h → "1 PM". * the prior look); 12h → "1 PM".

View File

@@ -110,8 +110,9 @@ 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.rememberTimelinePinchZoom
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.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -123,11 +124,6 @@ import kotlin.time.Clock
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's hour labels so they centre on the top bar's
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
* 4dp inset + 24dp half icon button), matching the week view. */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -534,8 +530,6 @@ private fun Timeline(
onDrop: (TimelineDrop) -> Unit, onDrop: (TimelineDrop) -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val zoom = LocalTimelineZoom.current val zoom = LocalTimelineZoom.current
val density = LocalDensity.current val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@@ -557,32 +551,11 @@ private fun Timeline(
Row(modifier = Modifier.fillMaxSize().then(pinch)) { 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( HourGutter(
modifier = Modifier scrollState = scrollState,
.width(GUTTER_WIDTH) hourHeight = hourHeight,
.padding(start = GUTTER_CONTENT_START_INSET) dragController = dragController,
.fillMaxHeight() )
.verticalScroll(scrollState),
) {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(hourHeight),
) {
if (h > 0) {
Text(
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
// Day column: rounded, clipped scroll viewport (permanent corners). // Day column: rounded, clipped scroll viewport (permanent corners).
Box( Box(
modifier = Modifier modifier = Modifier

View File

@@ -120,8 +120,10 @@ 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.rememberTimelinePinchZoom
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.GUTTER_CONTENT_START_INSET
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
@@ -137,11 +139,6 @@ import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's content (week badge + hour labels) so it centres
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
* (the app bar's 4dp inset + 24dp half icon button). */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Gap between day columns; part of the column pitch a drag maps positions through. */ /** Gap between day columns; part of the column pitch a drag maps positions through. */
@@ -670,8 +667,6 @@ private fun Timeline(
onDrop: (TimelineDrop) -> Unit, onDrop: (TimelineDrop) -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val zoom = LocalTimelineZoom.current val zoom = LocalTimelineZoom.current
val density = LocalDensity.current val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@@ -694,32 +689,11 @@ private fun Timeline(
Row(modifier = Modifier.fillMaxSize().then(pinch)) { 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( HourGutter(
modifier = Modifier scrollState = scrollState,
.width(GUTTER_WIDTH) hourHeight = hourHeight,
.padding(start = GUTTER_CONTENT_START_INSET) dragController = dragController,
.fillMaxHeight() )
.verticalScroll(scrollState),
) {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(hourHeight),
) {
if (h > 0) {
Text(
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
// Day columns: rounded, clipped scroll viewport (permanent corners). // Day columns: rounded, clipped scroll viewport (permanent corners).
Box( Box(
modifier = Modifier modifier = Modifier

View File

@@ -27,6 +27,14 @@ class TimeFormatTest {
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15") assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
} }
@Test
fun `gutter time drops the meridiem and clamps to the day`() {
assertThat(formatGutterTime(9 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("09:15")
assertThat(formatGutterTime(13 * 60 + 45, is24Hour = false, Locale.US)).isEqualTo("1:45")
assertThat(formatGutterTime(0, is24Hour = false, Locale.US)).isEqualTo("12:00")
assertThat(formatGutterTime(1_440, is24Hour = true, Locale.US)).isEqualTo("23:59")
}
@Test @Test
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() { fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13") assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")