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:
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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".
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user