diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt new file mode 100644 index 0000000..d92721c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt index 362efb1..8fbb23a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt @@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true } private const val PATTERN_24 = "HH:mm" private const val PATTERN_12 = "h:mm 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]. */ 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) } +/** + * 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 prior look); 12h → "1 PM". 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 44a26f9..4778c80 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 @@ -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.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.GUTTER_WIDTH +import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.week.TimedBlock import kotlinx.coroutines.flow.first @@ -123,11 +124,6 @@ import kotlin.time.Clock import java.util.Locale 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_VERTICAL_PADDING = 6.dp @@ -534,8 +530,6 @@ private fun Timeline( onDrop: (TimelineDrop) -> Unit, ) { val dark = isSystemInDarkTheme() - val use24Hour = LocalUse24HourFormat.current - val locale = currentLocale() val zoom = LocalTimelineZoom.current val density = LocalDensity.current val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl @@ -557,32 +551,11 @@ private fun Timeline( 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( - modifier = Modifier - .width(GUTTER_WIDTH) - .padding(start = GUTTER_CONTENT_START_INSET) - .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), - ) - } - } - } - } + HourGutter( + scrollState = scrollState, + hourHeight = hourHeight, + dragController = dragController, + ) // Day column: rounded, clipped scroll viewport (permanent corners). Box( modifier = Modifier 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 0fd71cf..9808f1e 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 @@ -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.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.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.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next @@ -137,11 +139,6 @@ import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle 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_VERTICAL_PADDING = 6.dp /** Gap between day columns; part of the column pitch a drag maps positions through. */ @@ -670,8 +667,6 @@ private fun Timeline( onDrop: (TimelineDrop) -> Unit, ) { val dark = isSystemInDarkTheme() - val use24Hour = LocalUse24HourFormat.current - val locale = currentLocale() val zoom = LocalTimelineZoom.current val density = LocalDensity.current val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl @@ -694,32 +689,11 @@ private fun Timeline( 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( - modifier = Modifier - .width(GUTTER_WIDTH) - .padding(start = GUTTER_CONTENT_START_INSET) - .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), - ) - } - } - } - } + HourGutter( + scrollState = scrollState, + hourHeight = hourHeight, + dragController = dragController, + ) // Day columns: rounded, clipped scroll viewport (permanent corners). Box( modifier = Modifier diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt index 4aca580..3fb84ce 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt @@ -27,6 +27,14 @@ class TimeFormatTest { 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 fun `hour label is zero-padded in 24h and compact am-pm in 12h`() { assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")