From 7f8a9069c04505ec5b7296e26d31f13c98e929b7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 09:36:40 +0200 Subject: [PATCH] feat(views): show a current-time line in day and week views A thin primary-coloured indicator (leading dot + line) marks the current time across today's column in the day and week timelines, positioned on the same HOUR_HEIGHT scale as the event blocks so it lines up with the grid. Shared ui/common/NowLine.kt ticks once a minute, re-aligning to each minute boundary to avoid drift, and only mounts on today's column so a single coroutine runs. Renders nothing when today isn't in view. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/NowLine.kt | 90 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 7 ++ .../calendula/ui/week/WeekScreen.kt | 7 ++ 3 files changed, 104 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt new file mode 100644 index 0000000..faa243f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt @@ -0,0 +1,90 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +private val DotSize = 8.dp +private val LineThickness = 2.dp + +/** + * Current wall-clock instant that updates once a minute, re-aligning to each + * minute boundary (rather than ticking a fixed 60 s) so it never drifts toward + * the middle of a minute. Drives the "now" line in the day/week grids. + */ +@Composable +private fun rememberCurrentMinute(): State { + val instant = remember { mutableStateOf(Clock.System.now()) } + LaunchedEffect(Unit) { + while (true) { + val now = Clock.System.now() + instant.value = now + delay(60_000L - now.toEpochMilliseconds() % 60_000L) + } + } + return instant +} + +/** + * A thin "current time" indicator — a leading dot plus a line — drawn across a + * day column. Positioned on the same [hourHeight] scale the event blocks use so + * it lines up with the grid, and refreshed each minute. Renders nothing unless + * the wall clock is on [date]; callers mount it only for the column showing + * today, so the per-minute tick runs on a single column. + */ +@Composable +fun NowLine( + date: LocalDate, + hourHeight: Dp, + modifier: Modifier = Modifier, +) { + val now by rememberCurrentMinute() + val local = now.toLocalDateTime(TimeZone.currentSystemDefault()) + if (local.date != date) return + + val minutes = local.hour * 60 + local.minute + val top = hourHeight * (minutes / 60f) + val color = MaterialTheme.colorScheme.primary + Box( + modifier = modifier + .fillMaxWidth() + .height(DotSize) + .offset(y = top - DotSize / 2), + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(LineThickness) + .background(color), + ) + // The dot anchors the line to the gutter edge, mirroring the standard + // calendar "now" marker; drawn after the line so it sits on top. + Box( + modifier = Modifier + .size(DotSize) + .background(color, CircleShape), + ) + } +} 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 9e7ef84..6b98251 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 @@ -71,6 +71,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.next @@ -496,6 +497,7 @@ private fun Timeline( blocks = state.timed, dark = dark, date = state.date, + today = state.today, onEventClick = onEventClick, onCreateAt = onCreateAt, modifier = Modifier @@ -512,6 +514,7 @@ private fun DayColumnCard( blocks: List, dark: Boolean, date: LocalDate, + today: LocalDate, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, @@ -556,6 +559,10 @@ private fun DayColumnCard( .padding(horizontal = 1.dp), ) } + // Current-time line, on top of the events, only on today's column. + if (date == today) { + NowLine(date = date, hourHeight = HOUR_HEIGHT) + } } } } 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 3e3660f..0fbfc6c 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 @@ -76,6 +76,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.currentLocale @@ -610,6 +611,7 @@ private fun Timeline( blocks = state.timedByDay[day].orEmpty(), dark = dark, date = day, + today = state.today, onEventClick = onEventClick, onCreateAt = onCreateAt, modifier = Modifier @@ -628,6 +630,7 @@ private fun DayColumnCard( blocks: List, dark: Boolean, date: LocalDate, + today: LocalDate, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, @@ -671,6 +674,10 @@ private fun DayColumnCard( .padding(horizontal = 1.dp), ) } + // Current-time line, on top of the events, only on today's column. + if (date == today) { + NowLine(date = date, hourHeight = HOUR_HEIGHT) + } } } }