From f7e9990c4e8d03f22745beadfa4b4982852c55da Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 2 Sep 2026 21:23:07 +0200 Subject: [PATCH] Show the start time on wide month chips (#219) --- .../calendula/ui/month/MonthChipTime.kt | 84 +++++++++++++++++++ .../calendula/ui/month/MonthScreen.kt | 47 +++++++++-- .../calendula/ui/month/MonthChipTimeTest.kt | 71 ++++++++++++++++ 3 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt new file mode 100644 index 0000000..9ebf705 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt @@ -0,0 +1,84 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay +import de.jeanlucmakiola.floret.locale.currentLocale +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import java.util.Locale + +/** Padding between a month chip's edge and its text. */ +internal val MONTH_CHIP_TEXT_PADDING = 4.dp + +/** A chip's own inset inside its day cell, on top of the cell's gap. */ +internal val MONTH_CHIP_INSET = CELL_GAP + 1.dp + +/** Horizontal space a chip spends on chrome rather than on text, both sides. */ +private val CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f + +/** + * The seven characters of title a chip has to keep for the time to be worth its + * place (#219) — the threshold is a readable title, so it is stated in title + * characters and priced at the chip's own text style rather than guessed in dp. + * Lowercase Latin of average advance: not "iii", which would let the time in on + * a column where nothing else fits, nor "WWW", which would keep it out of one + * with room to spare. + * + * Seven rather than a rounder eight because the 12-hour convention spends a + * meridiem the 24-hour one does not, and eight would have cost a landscape + * phone the time in 12-hour while granting it in 24-hour. A narrow portrait + * column is nowhere near either figure, so the gate is unchanged where it + * matters most. + */ +private const val TITLE_SAMPLE = "notepad" + +/** The widest wall-clock time in either convention: two-digit hour, plus a meridiem in 12-hour. */ +private const val SAMPLE_HOUR = 12 +private const val SAMPLE_MINUTE = 45 + +/** + * The start time a chip shows before its title (#219), or null when it has none + * to show: an all-day chip never carries a time, and neither does a bar carried + * in from an earlier week, whose start is not in this segment. + */ +internal fun monthChipTime( + event: EventInstance, + continuesLeft: Boolean, + zone: TimeZone, + is24Hour: Boolean, + locale: Locale, +): String? { + if (event.isAllDay || continuesLeft) return null + val start = event.start.toLocalDateTime(zone).time + return formatTimeOfDay(start.hour, start.minute, is24Hour, locale) +} + +/** + * The narrowest chip that may show a time (#219): wide enough for the widest + * time in the current convention plus [TITLE_SAMPLE]'s worth of title. + * + * Measured rather than a device breakpoint, so it follows the font scale, the + * 12/24-hour setting, the locale's own time format and the week-number gutter + * on its own — landscape, an unfolded foldable and a tablet all come out wide + * enough without any of them being named. + */ +@Composable +internal fun rememberMonthTimeChipWidth(): Dp { + val measurer = rememberTextMeasurer() + val style = MaterialTheme.typography.labelSmall + val density = LocalDensity.current + val locale = currentLocale() + val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale) + return remember(sample, style, density, measurer) { + val textPx = measurer.measure("$sample $TITLE_SAMPLE", style).size.width + with(density) { textPx.toDp() } + CHIP_CHROME + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index a4dce26..8d37409 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -146,6 +146,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape @@ -499,6 +500,7 @@ private fun MonthDragOverlay(controller: MonthDragController) { var origin by remember { mutableStateOf(Offset.Zero) } val dark = isSystemInDarkTheme() val density = LocalDensity.current + val timeChipWidth = rememberMonthTimeChipWidth() val reduceMotion = rememberReduceMotion() val moveInFlight = moveInFlight() // Here rather than beside the controller: this reads [drag], which changes @@ -556,6 +558,9 @@ private fun MonthDragOverlay(controller: MonthDragController) { dark = dark, continuesLeft = false, continuesRight = false, + // The copy is a cut-out of the chip it lifted off, so it answers the + // gate at the same width the grid did. + showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth, modifier = Modifier // Absolute: these are root coordinates, and the direction-aware // offset would mirror them across the screen in an RTL layout. @@ -568,7 +573,7 @@ private fun MonthDragOverlay(controller: MonthDragController) { } .width(with(density) { drag.sizePx.width.toDp() }) .height(with(density) { drag.sizePx.height.toDp() }) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) + .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp) .graphicsLayer { scaleX = 1f + 0.04f * lift scaleY = 1f + 0.04f * lift @@ -758,7 +763,7 @@ private val DAY_NUMBER_HEIGHT = 22.dp private val WEEK_NUMBER_GUTTER = 40.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp -private val CELL_GAP = 2.dp +internal val CELL_GAP = 2.dp /** Named separately because the split style's selection outline draws its own * rounded rect and has to match this radius exactly. */ private val CELL_CORNER = 12.dp @@ -1983,6 +1988,10 @@ private fun MonthWeekRow( ), ) { val colW = maxWidth / 7 + // The width a chip needs before its start time is worth the title + // characters it costs (#219) — measured here, so the same column in + // landscape or on a foldable answers differently on its own. + val timeChipWidth = rememberMonthTimeChipWidth() // Per-day background pills — same surfaceContainer rounded surface the // week/day views use, so the three views share one visual language. @@ -2062,6 +2071,7 @@ private fun MonthWeekRow( continuesLeft = span.continuesLeft, continuesRight = span.continuesRight, days = week.days.subList(span.startCol, span.endCol + 1), + showTime = colW * cols >= timeChipWidth, modifier = Modifier .offset( x = colW * span.startCol, @@ -2085,7 +2095,7 @@ private fun MonthWeekRow( ) .width(colW * cols) .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), ) // One invisible slice of the bar per further column it // covers. A multi-day event has a dot on every day but @@ -2109,7 +2119,7 @@ private fun MonthWeekRow( ) .width(colW) .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), ) } } @@ -2132,6 +2142,7 @@ private fun MonthWeekRow( continuesLeft = false, continuesRight = false, days = listOf(d), + showTime = colW >= timeChipWidth, modifier = Modifier .offset( x = colW * col, @@ -2140,7 +2151,7 @@ private fun MonthWeekRow( .morphBounds(MonthMorphKey.Event(d, ev.instanceId)) .width(colW) .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), ) } val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size @@ -2427,7 +2438,10 @@ private fun shortMonthName(date: LocalDate): String { } } -/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ +/** + * A filled event pill/bar — softened (or raw) fill, title clipped to one line, + * with the start time before it where the chip is wide enough ([showTime], #219). + */ @Composable private fun MonthBar( event: de.jeanlucmakiola.calendula.domain.EventInstance, @@ -2441,8 +2455,23 @@ private fun MonthBar( * the provider hands the re-read instance a new one. */ days: List? = null, + /** Whether this chip has the width to carry its start time (#219). */ + showTime: Boolean = false, ) { + val zone = remember { TimeZone.currentSystemDefault() } + val time = if (showTime) { + monthChipTime( + event = event, + continuesLeft = continuesLeft, + zone = zone, + is24Hour = LocalUse24HourFormat.current, + locale = currentLocale(), + ) + } else { + null + } val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + val label = if (time != null) "$time $title" else title val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val soften = LocalSoftenColors.current @@ -2458,16 +2487,16 @@ private fun MonthBar( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .background(fill, shape) - .padding(horizontal = 4.dp) + .padding(horizontal = MONTH_CHIP_TEXT_PADDING) .semantics { - contentDescription = title + contentDescription = label if (moveAction != null) customActions = listOf(moveAction) }, contentAlignment = Alignment.CenterStart, ) { val titleOverflow = eventTitleOverflow() Text( - text = title, + text = label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = titleOverflow.overflow, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt new file mode 100644 index 0000000..c517be7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt @@ -0,0 +1,71 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test +import java.util.Locale + +/** Which month chips carry a start time, and how it reads (#219). */ +class MonthChipTimeTest { + + private val zone = TimeZone.UTC + private val locale = Locale.UK + private val day = LocalDate(2026, Month.SEPTEMBER, 2) + + private fun timed(hour: Int, minute: Int) = EventInstance( + instanceId = 1L, + eventId = 1L, + calendarId = 1L, + title = "Standup", + start = day.atTime(hour, minute).toInstant(zone), + end = day.atTime(hour + 1, minute).toInstant(zone), + isAllDay = false, + color = 0, + location = null, + ) + + private fun allDay() = EventInstance( + instanceId = 2L, + eventId = 2L, + calendarId = 1L, + title = "Holiday", + start = day.atTime(0, 0).toInstant(zone), + end = day.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone), + isAllDay = true, + color = 0, + location = null, + ) + + @Test + fun `a timed chip shows its start in the 24-hour convention`() { + val time = monthChipTime(timed(9, 5), continuesLeft = false, zone, is24Hour = true, locale) + assertThat(time).isEqualTo("09:05") + } + + @Test + fun `the 12-hour setting is honoured`() { + val time = monthChipTime(timed(14, 30), continuesLeft = false, zone, is24Hour = false, locale) + assertThat(time).isEqualTo("2:30 pm") + } + + @Test + fun `an all-day chip never shows a time`() { + val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale) + assertThat(time).isNull() + } + + @Test + fun `a bar carried in from the previous week shows none either`() { + // Its start is not in this segment, so printing it would put a time on a + // row the event does not begin on. + val time = monthChipTime(timed(9, 5), continuesLeft = true, zone, is24Hour = true, locale) + assertThat(time).isNull() + } +}