From f7e9990c4e8d03f22745beadfa4b4982852c55da Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 2 Sep 2026 21:23:07 +0200 Subject: [PATCH 1/4] 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() + } +} From 065afdb2b892e2edb4fae0725eac0eb42cca388f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 11:56:36 +0200 Subject: [PATCH 2/4] Set the month chip time apart from its title (#219) --- .../calendula/ui/month/MonthChipTime.kt | 41 ++++++++++++++++++- .../calendula/ui/month/MonthScreen.kt | 9 ++-- .../calendula/ui/month/MonthChipTimeTest.kt | 22 ++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) 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 index 9ebf705..7163158 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt @@ -3,12 +3,20 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.floret.locale.currentLocale import kotlinx.datetime.TimeZone @@ -44,6 +52,36 @@ private const val TITLE_SAMPLE = "notepad" private const val SAMPLE_HOUR = 12 private const val SAMPLE_MINUTE = 45 +/** + * Ink for a chip's title, against [SECONDARY_INK_ALPHA] for the time in front of + * it — the same pairing the week and day blocks put a title and its time label + * in, so a chip that carries both reads the way a block does. + */ +internal const val TITLE_INK_ALPHA = 0.85f + +/** + * How the time is set apart from the title it precedes (#219). labelSmall's + * medium weight steps down to regular and its 0.5sp tracking closes up, so the + * two read as a time and a title rather than as one run-on string. Both moves + * also narrow the prefix, which is worth most on the column that only just + * earned it. + * + * The ink is handed in rather than dimmed further: at this size a fainter grey + * would give up the contrast [SECONDARY_INK_ALPHA] exists to hold, so the + * weight carries the difference instead. + */ +private val TIME_SPAN = SpanStyle(fontWeight = FontWeight.Normal, letterSpacing = 0.sp) + +/** A chip's text — its [time], set apart in [timeInk], before the [title]. */ +internal fun monthChipLabel(time: String?, title: String, timeInk: Color): AnnotatedString = + buildAnnotatedString { + if (time != null) { + withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) } + append(" ") + } + append(title) + } + /** * 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 @@ -78,7 +116,8 @@ internal fun rememberMonthTimeChipWidth(): Dp { 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 + val label = monthChipLabel(sample, TITLE_SAMPLE, Color.Unspecified) + val textPx = measurer.measure(label, 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 8d37409..90bd503 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 @@ -147,6 +147,7 @@ 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.SECONDARY_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape @@ -2471,11 +2472,13 @@ private fun MonthBar( 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 val fill = eventFill(event.color, dark, soften) + // The same title/secondary ink pairing the week and day blocks use, with + // the time on the quieter half. + val label = monthChipLabel(time, title, eventInk(fill, alpha = SECONDARY_INK_ALPHA)) val moveAction = eventMoveAction(event) // The source stays put as a ghost while its floating copy travels. val monthDrag = LocalMonthDrag.current @@ -2489,7 +2492,7 @@ private fun MonthBar( .background(fill, shape) .padding(horizontal = MONTH_CHIP_TEXT_PADDING) .semantics { - contentDescription = label + contentDescription = label.text if (moveAction != null) customActions = listOf(moveAction) }, contentAlignment = Alignment.CenterStart, @@ -2501,7 +2504,7 @@ private fun MonthBar( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill), + color = eventInk(fill, alpha = TITLE_INK_ALPHA), textDecoration = declinedDecoration(event.isDeclined), ) } 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 index c517be7..21698d6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.calendula.ui.month +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.DateTimeUnit @@ -55,6 +57,26 @@ class MonthChipTimeTest { assertThat(time).isEqualTo("2:30 pm") } + @Test + fun `the time is set apart from the title it precedes`() { + val label = monthChipLabel("09:05", "Standup", Color.Black) + assertThat(label.text).isEqualTo("09:05 Standup") + // Only the time is restyled: the title keeps labelSmall as the theme + // sets it, so the two read as separate things on one line (#219). + val spans = label.spanStyles + assertThat(spans).hasSize(1) + assertThat(spans.single().start).isEqualTo(0) + assertThat(spans.single().end).isEqualTo("09:05".length) + assertThat(spans.single().item.fontWeight).isEqualTo(FontWeight.Normal) + } + + @Test + fun `a chip without a time is styled title and nothing else`() { + val label = monthChipLabel(null, "Standup", Color.Black) + assertThat(label.text).isEqualTo("Standup") + assertThat(label.spanStyles).isEmpty() + } + @Test fun `an all-day chip never shows a time`() { val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale) From 5be8884e9fef142ce3cc130e48d8d1ed1b0c8d53 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 12:09:03 +0200 Subject: [PATCH 3/4] Set event times in regular weight across the timeline views (#219) --- .../calendula/ui/common/BlockPlacement.kt | 3 +- .../calendula/ui/common/EventTimeStyle.kt | 93 +++++++++++++++++++ .../calendula/ui/common/TimelineDrag.kt | 3 +- .../calendula/ui/month/MonthChipTime.kt | 88 +----------------- .../calendula/ui/month/MonthScreen.kt | 4 +- .../calendula/ui/month/MonthChipTimeTest.kt | 5 +- 6 files changed, 107 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index 265a042..e6f9eab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -172,7 +172,8 @@ fun BlockTimeLabel( ) { text -> Text( text = text, - style = MaterialTheme.typography.labelSmall, + // Regular weight against the title's medium above it (#219). + style = MaterialTheme.typography.labelSmall.asEventTime(), maxLines = maxLines, overflow = overflow.overflow, softWrap = overflow.softWrap, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt new file mode 100644 index 0000000..85ba7f9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt @@ -0,0 +1,93 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp +import de.jeanlucmakiola.floret.locale.currentLocale + +/** + * How an event's time is set against its title (#219). + * + * The label styles carry a medium weight and 0.5sp of tracking, which left a + * time reading as part of the title next to it. The time steps down to regular + * and closes the tracking up, so the two read as a time and a title — whether + * they sit on one line, as they do on a wide month chip, or on two, as they do + * in a timed block. + * + * The ink is not dimmed further to do it: at this size a fainter grey would + * give up the contrast [SECONDARY_INK_ALPHA] exists to hold, so the weight + * carries the difference instead. + */ +private val TIME_WEIGHT = FontWeight.Normal +private val TIME_TRACKING = 0.sp + +/** Ink for a title, against [SECONDARY_INK_ALPHA] for the time beside it. */ +const val TITLE_INK_ALPHA = 0.85f + +/** [this] set as an event's time rather than as its title. */ +fun TextStyle.asEventTime(): TextStyle = + copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING) + +/** The same, for a time that shares one line with the title after it. */ +private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING) + +/** Text reading [time], set apart in [timeInk], before [title]. */ +fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString = + buildAnnotatedString { + if (time != null) { + withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) } + append(" ") + } + append(title) + } + +/** + * The characters of title that have to survive a time prefix for it to be worth + * its place — the threshold is a readable title, so it is stated in title + * characters and priced at the surface's own text style rather than guessed in + * dp. Lowercase Latin of average advance: not "iii", which would let the time + * in where nothing else fits, nor "WWW", which would keep it out of a column + * 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, and a meridiem in 12-hour. */ +private const val SAMPLE_HOUR = 12 +private const val SAMPLE_MINUTE = 45 + +/** + * The narrowest run of [style] text that may carry a time in front of a title: + * wide enough for the widest time in the current convention plus [TITLE_SAMPLE]. + * + * Measured rather than a device breakpoint, so it follows the font scale, the + * 12/24-hour setting, the locale's own time format and whatever else has + * already been taken off the width — landscape, an unfolded foldable and a + * tablet all come out wide enough without any of them being named. + */ +@Composable +fun rememberInlineTimeWidth(style: TextStyle): Dp { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val locale = currentLocale() + val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale) + return remember(sample, style, density, measurer) { + val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified) + with(density) { measurer.measure(label, style).size.width.toDp() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 18b0ad1..93674b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -738,7 +738,8 @@ private fun DragCopy( if (label != null) { Text( text = label, - style = MaterialTheme.typography.labelSmall, + // As the block it lifted off sets its own time (#219). + style = MaterialTheme.typography.labelSmall.asEventTime(), maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, 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 index 7163158..175bbe4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt @@ -2,23 +2,11 @@ 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.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay -import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import java.util.Locale @@ -32,56 +20,6 @@ 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 - -/** - * Ink for a chip's title, against [SECONDARY_INK_ALPHA] for the time in front of - * it — the same pairing the week and day blocks put a title and its time label - * in, so a chip that carries both reads the way a block does. - */ -internal const val TITLE_INK_ALPHA = 0.85f - -/** - * How the time is set apart from the title it precedes (#219). labelSmall's - * medium weight steps down to regular and its 0.5sp tracking closes up, so the - * two read as a time and a title rather than as one run-on string. Both moves - * also narrow the prefix, which is worth most on the column that only just - * earned it. - * - * The ink is handed in rather than dimmed further: at this size a fainter grey - * would give up the contrast [SECONDARY_INK_ALPHA] exists to hold, so the - * weight carries the difference instead. - */ -private val TIME_SPAN = SpanStyle(fontWeight = FontWeight.Normal, letterSpacing = 0.sp) - -/** A chip's text — its [time], set apart in [timeInk], before the [title]. */ -internal fun monthChipLabel(time: String?, title: String, timeInk: Color): AnnotatedString = - buildAnnotatedString { - if (time != null) { - withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) } - append(" ") - } - append(title) - } - /** * 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 @@ -99,25 +37,7 @@ internal fun monthChipTime( 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. - */ +/** The narrowest chip that may show a time — the text it needs, plus its chrome. */ @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 label = monthChipLabel(sample, TITLE_SAMPLE, Color.Unspecified) - val textPx = measurer.measure(label, style).size.width - with(density) { textPx.toDp() } + CHIP_CHROME - } -} +internal fun rememberMonthTimeChipWidth(): Dp = + rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + 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 90bd503..5922b83 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 @@ -148,6 +148,8 @@ 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.SECONDARY_INK_ALPHA +import de.jeanlucmakiola.calendula.ui.common.TITLE_INK_ALPHA +import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape @@ -2478,7 +2480,7 @@ private fun MonthBar( val fill = eventFill(event.color, dark, soften) // The same title/secondary ink pairing the week and day blocks use, with // the time on the quieter half. - val label = monthChipLabel(time, title, eventInk(fill, alpha = SECONDARY_INK_ALPHA)) + val label = inlineTimeLabel(time, title, eventInk(fill, alpha = SECONDARY_INK_ALPHA)) val moveAction = eventMoveAction(event) // The source stays put as a ghost while its floating copy travels. val monthDrag = LocalMonthDrag.current 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 index 21698d6..bccea31 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate @@ -59,7 +60,7 @@ class MonthChipTimeTest { @Test fun `the time is set apart from the title it precedes`() { - val label = monthChipLabel("09:05", "Standup", Color.Black) + val label = inlineTimeLabel("09:05", "Standup", Color.Black) assertThat(label.text).isEqualTo("09:05 Standup") // Only the time is restyled: the title keeps labelSmall as the theme // sets it, so the two read as separate things on one line (#219). @@ -72,7 +73,7 @@ class MonthChipTimeTest { @Test fun `a chip without a time is styled title and nothing else`() { - val label = monthChipLabel(null, "Standup", Color.Black) + val label = inlineTimeLabel(null, "Standup", Color.Black) assertThat(label.text).isEqualTo("Standup") assertThat(label.spanStyles).isEmpty() } From 72be175fcae7c5fc019e549581f6a0f702c47616 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 14:07:55 +0200 Subject: [PATCH 4/4] Hold the time apart from the title on any font (#219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The medium-to-regular step is a no-op on Atkinson Hyperlegible, JetBrains Mono and imported fonts, which ship no medium face — so the title takes full ink against the time's 0.8 instead, and the four title sites share the constant. Also: the drag copy carries the time its source chip had rather than deriving one with continuesLeft hardcoded false; the month grid takes its zone from the state; times are formatted once per row and the chip width measured once per grid; the chip geometry lives in one file; and a screen reader hears the time whether or not the chip is wide enough to draw it. --- CHANGELOG.md | 8 ++ .../calendula/ui/common/EventTimeStyle.kt | 48 ++++------ .../calendula/ui/common/TimelineDrag.kt | 2 +- .../calendula/ui/day/DayScreen.kt | 10 ++- .../calendula/ui/month/MonthChipGeometry.kt | 19 ++++ .../calendula/ui/month/MonthChipTime.kt | 12 +-- .../calendula/ui/month/MonthDrag.kt | 12 +++ .../calendula/ui/month/MonthScreen.kt | 89 ++++++++++++++----- .../calendula/ui/month/MonthUiState.kt | 11 ++- .../calendula/ui/month/MonthViewModel.kt | 1 + .../calendula/ui/week/WeekScreen.kt | 10 ++- .../calendula/ui/common/EventTimeStyleTest.kt | 61 +++++++++++++ .../calendula/ui/month/MonthChipTimeTest.kt | 26 +----- 13 files changed, 210 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipGeometry.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ae0ac2c..d773ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the title now simply runs to the edge of its chip — a few more letters per cell, which is often the difference between two events you can tell apart and two you can't ([#164]). +- Month-view events now show their start time before the title, where the chip + is wide enough to keep the title readable — in landscape, on a foldable and on + a tablet, and never at the cost of a narrow phone column. All-day events show + no time, and neither does a bar carried in from the previous week, whose start + is not in that row. Event times across the month, week and day views are also + set in a regular weight against the title's, so a time reads as a time rather + than as part of the name next to it ([#219]). - The calendar titles now shorten instead of being cut off. When the full month name doesn't fit the top bar, the month and week views fall back to its three-letter form and the day view drops the weekday, rather than trailing off @@ -1583,5 +1590,6 @@ automatically, with zero telemetry and no internet permission. [#225]: https://codeberg.org/jlmakiola/calendula/issues/225 [#228]: https://codeberg.org/jlmakiola/calendula/issues/228 [#234]: https://codeberg.org/jlmakiola/calendula/issues/234 +[#219]: https://codeberg.org/jlmakiola/calendula/issues/219 [#248]: https://codeberg.org/jlmakiola/calendula/issues/248 [#253]: https://codeberg.org/jlmakiola/calendula/issues/253 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt index 85ba7f9..fdd38e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt @@ -15,32 +15,26 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp import de.jeanlucmakiola.floret.locale.currentLocale -/** - * How an event's time is set against its title (#219). - * - * The label styles carry a medium weight and 0.5sp of tracking, which left a - * time reading as part of the title next to it. The time steps down to regular - * and closes the tracking up, so the two read as a time and a title — whether - * they sit on one line, as they do on a wide month chip, or on two, as they do - * in a timed block. - * - * The ink is not dimmed further to do it: at this size a fainter grey would - * give up the contrast [SECONDARY_INK_ALPHA] exists to hold, so the weight - * carries the difference instead. - */ private val TIME_WEIGHT = FontWeight.Normal private val TIME_TRACKING = 0.sp +private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING) -/** Ink for a title, against [SECONDARY_INK_ALPHA] for the time beside it. */ -const val TITLE_INK_ALPHA = 0.85f +/** + * Ink for an event's title, against [SECONDARY_INK_ALPHA] for the time beside or + * beneath it. Full, so the pair separates on a typeface with no medium weight to + * step down from — Atkinson Hyperlegible and JetBrains Mono ship regular and bold + * only, and a user's imported font a single face, so [asEventTime] is a no-op + * there and the ink is the whole difference (#219). + */ +const val TITLE_INK_ALPHA = 1f -/** [this] set as an event's time rather than as its title. */ +/** + * [this] set as an event's time rather than as its title: the label styles' medium + * weight and 0.5sp of tracking left a time reading as part of the title next to it. + */ fun TextStyle.asEventTime(): TextStyle = copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING) -/** The same, for a time that shares one line with the title after it. */ -private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING) - /** Text reading [time], set apart in [timeInk], before [title]. */ fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString = buildAnnotatedString { @@ -53,17 +47,8 @@ fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedStri /** * The characters of title that have to survive a time prefix for it to be worth - * its place — the threshold is a readable title, so it is stated in title - * characters and priced at the surface's own text style rather than guessed in - * dp. Lowercase Latin of average advance: not "iii", which would let the time - * in where nothing else fits, nor "WWW", which would keep it out of a column - * 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. + * its place — lowercase Latin of average advance, priced at the surface's own + * text style rather than guessed in dp. */ private const val TITLE_SAMPLE = "notepad" @@ -77,8 +62,7 @@ private const val SAMPLE_MINUTE = 45 * * Measured rather than a device breakpoint, so it follows the font scale, the * 12/24-hour setting, the locale's own time format and whatever else has - * already been taken off the width — landscape, an unfolded foldable and a - * tablet all come out wide enough without any of them being named. + * already been taken off the width. */ @Composable fun rememberInlineTimeWidth(style: TextStyle): Dp { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 93674b3..7400e61 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -733,7 +733,7 @@ private fun DragCopy( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill, alpha = 0.85f), + color = eventInk(fill, alpha = TITLE_INK_ALPHA), ) if (label != null) { Text( 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 8ee12d5..6925fec 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 @@ -119,7 +119,9 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION +import de.jeanlucmakiola.calendula.ui.common.asEventTime import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA +import de.jeanlucmakiola.calendula.ui.common.TITLE_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay @@ -526,7 +528,7 @@ private fun AllDayBar( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill), + color = eventInk(fill, alpha = TITLE_INK_ALPHA), textDecoration = declinedDecoration(event.isDeclined), ) } @@ -749,7 +751,9 @@ private fun EventBlock( val timeMaxLines = if (showTime && spare >= timeLineHeight) { blockTextLines( text = timeLabel, - style = MaterialTheme.typography.labelSmall, + // The style it is drawn in, or the budget measures a line the label + // never uses (#219). + style = MaterialTheme.typography.labelSmall.asEventTime(), textWidth = textWidth, max = MAX_TIME_LINES, ) @@ -800,7 +804,7 @@ private fun EventBlock( title = title, maxLines = titleMaxLines, textWidth = textWidth, - color = eventInk(fill, alpha = 0.85f), + color = eventInk(fill, alpha = TITLE_INK_ALPHA), textDecoration = declinedDecoration(block.event.isDeclined), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipGeometry.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipGeometry.kt new file mode 100644 index 0000000..83c4dbe --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipGeometry.kt @@ -0,0 +1,19 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.ui.unit.dp + +// The month grid's chip geometry, together in one file: these derive from each +// other, and a top-level val reading one across a file boundary would resolve +// against whichever class the JVM happened to initialise first. + +/** Gap between a day cell and its neighbours. */ +internal val CELL_GAP = 2.dp + +/** 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. */ +internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f 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 index 175bbe4..78d05dd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt @@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth @@ -11,15 +10,6 @@ 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 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 @@ -40,4 +30,4 @@ internal fun monthChipTime( /** The narrowest chip that may show a time — the text it needs, plus its chrome. */ @Composable internal fun rememberMonthTimeChipWidth(): Dp = - rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + CHIP_CHROME + rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt index 809a618..746dad4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -97,6 +97,13 @@ data class MonthChipDrag( val targetDate: LocalDate?, val topLeftInRoot: Offset, val sizePx: IntSize, + /** + * The start time of the chip this lifted off, already formatted (#219). + * Carried rather than re-derived: the overlay has no row to ask whether the + * bar was carried in from the previous week, and no zone the grid laid out + * in. Null where the source chip had no time to show. + */ + val time: String? = null, ) /** Where a finished chip drag asks its event to go, as a whole-day shift. */ @@ -172,6 +179,7 @@ class MonthDragController { private var event: EventInstance? = null private var grabDate: LocalDate? = null + private var time: String? = null private var grab = Offset.Zero private var pointer = Offset.Zero private var sizePx = IntSize.Zero @@ -190,9 +198,11 @@ class MonthDragController { pointerInRoot: Offset, chipInRoot: Offset, size: IntSize, + time: String? = null, ) { this.event = event this.grabDate = grabDate + this.time = time settling = null isDragging = true liftedInstanceId = event.instanceId @@ -210,6 +220,7 @@ class MonthDragController { fun cancel() { event = null grabDate = null + time = null isDragging = false liftedInstanceId = null drag = null @@ -331,6 +342,7 @@ class MonthDragController { targetDate = resolved ?: drag?.targetDate, topLeftInRoot = pointer - grab, sizePx = sizePx, + time = time, ) } } 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 5922b83..5328653 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 @@ -123,6 +123,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -561,8 +562,10 @@ 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. + // The time the source chip carried, drawn only if this one-column + // cut-out of it has the room — which a slice of a multi-day bar has + // not, however wide the bar was. + time = drag.time, showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth, modifier = Modifier // Absolute: these are root coordinates, and the direction-aware @@ -766,7 +769,6 @@ 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 -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 @@ -815,10 +817,15 @@ internal fun MonthGrid( verticalArrangement = Arrangement.spacedBy(2.dp), ) { val month = state.month + // Once per grid: the value depends on the typography, the density, the + // locale and the 24-hour setting, none of which vary by row (#219). + val timeChipWidth = rememberMonthTimeChipWidth() state.weeks.forEach { week -> MonthWeekRow( week = week, today = state.today, + zone = state.zone, + timeChipWidth = timeChipWidth, inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, @@ -858,6 +865,7 @@ internal fun ContinuousMonthGrid( ) { val monthCount = remember { continuousMonthCount() } val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) } + val timeChipWidth = rememberMonthTimeChipWidth() LazyColumn( state = listState, modifier = modifier.fillMaxSize(), @@ -879,6 +887,8 @@ internal fun ContinuousMonthGrid( weeks = state.monthsByIndex[index], weekStart = state.weekStart, today = state.today, + zone = state.zone, + timeChipWidth = timeChipWidth, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -899,6 +909,8 @@ private fun ContinuousMonthBlock( weeks: List?, weekStart: DayOfWeek, today: LocalDate, + zone: TimeZone, + timeChipWidth: Dp, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -918,6 +930,8 @@ private fun ContinuousMonthBlock( MonthWeekRow( week = week, today = today, + zone = zone, + timeChipWidth = timeChipWidth, inMonth = { it.month == month.month && it.year == month.year }, // The block owns its month alone: a day from either // neighbour is left out entirely rather than dimmed. @@ -987,6 +1001,7 @@ internal fun DenseMonthGrid( modifier: Modifier = Modifier, ) { val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } + val timeChipWidth = rememberMonthTimeChipWidth() LazyColumn( state = listState, modifier = modifier @@ -1009,6 +1024,8 @@ internal fun DenseMonthGrid( MonthWeekRow( week = week, today = state.today, + zone = state.zone, + timeChipWidth = timeChipWidth, // Every day in the stream belongs to a month equally — there // is no "other month" to recede here. inMonth = { true }, @@ -1894,6 +1911,10 @@ private fun rememberSkeletonPulse(): Float { private fun MonthWeekRow( week: MonthWeek, today: LocalDate, + /** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */ + zone: TimeZone, + /** The narrowest chip that may carry a start time, measured once per grid (#219). */ + timeChipWidth: Dp, inMonth: (LocalDate) -> Boolean, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, @@ -1913,6 +1934,27 @@ private fun MonthWeekRow( val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) val morphing = morphInFlight() + // Every chip's start time for this row at once, and only when the row's own + // inputs change: formatting is a parsed pattern per call, and the dim cutoff + // ticks every minute while a drag recomposes every frame (#219). + val is24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + val chipTimes = remember(week, zone, is24Hour, locale) { + buildMap { + week.spans.forEach { span -> + put( + span.event.instanceId, + monthChipTime(span.event, span.continuesLeft, zone, is24Hour, locale), + ) + } + week.timedByDay.values.flatten().forEach { event -> + put( + event.instanceId, + monthChipTime(event, continuesLeft = false, zone, is24Hour, locale), + ) + } + } + } // Drag to reschedule (#68). The chips can take no pointer input of their own // — the full-bleed tap layer sits on top of them — so one detector on the @@ -1987,14 +2029,13 @@ private fun MonthWeekRow( band = bandCoordinates, rowHeightPx = rowHeightPx, isRtl = isRtl, + chipTimes = chipTimes, ), ), ) { + // What a chip has to spend, against the [timeChipWidth] a start time + // costs it (#219). 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. @@ -2074,6 +2115,7 @@ private fun MonthWeekRow( continuesLeft = span.continuesLeft, continuesRight = span.continuesRight, days = week.days.subList(span.startCol, span.endCol + 1), + time = chipTimes[span.event.instanceId], showTime = colW * cols >= timeChipWidth, modifier = Modifier .offset( @@ -2145,6 +2187,7 @@ private fun MonthWeekRow( continuesLeft = false, continuesRight = false, days = listOf(d), + time = chipTimes[ev.instanceId], showTime = colW >= timeChipWidth, modifier = Modifier .offset( @@ -2306,6 +2349,9 @@ private fun monthChipDragModifier( band: Array, rowHeightPx: Float, isRtl: Boolean, + /** The row's formatted chip times by instance id, so the copy carries the + * one its source chip had rather than deriving another (#219). */ + chipTimes: Map, ): Modifier = rememberDragSurface( enabled = moveScope?.dragEnabled == true && controller != null, key = week.days.first(), @@ -2334,6 +2380,7 @@ private fun monthChipDragModifier( y = requireNotNull(bandTop) + lane * rowHeightPx, ), size = IntSize(columnPx.toInt(), rowHeightPx.toInt()), + time = chipTimes[event.instanceId], ) true } @@ -2458,21 +2505,11 @@ 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). */ + /** The chip's start time, or null where it has none — see `monthChipTime` (#219). */ + time: String? = null, + /** Whether this chip has the width to draw [time]; a screen reader gets it either way. */ 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 dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) @@ -2480,7 +2517,15 @@ private fun MonthBar( val fill = eventFill(event.color, dark, soften) // The same title/secondary ink pairing the week and day blocks use, with // the time on the quieter half. - val label = inlineTimeLabel(time, title, eventInk(fill, alpha = SECONDARY_INK_ALPHA)) + val label = inlineTimeLabel( + time = time.takeIf { showTime }, + title = title, + timeInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + ) + // Announced whether or not it is drawn, and comma-separated as the week and + // day blocks do it: what a screen reader hears shouldn't turn on how wide + // the chip happens to be (#219). + val description = if (time != null) "$title, $time" else title val moveAction = eventMoveAction(event) // The source stays put as a ghost while its floating copy travels. val monthDrag = LocalMonthDrag.current @@ -2494,7 +2539,7 @@ private fun MonthBar( .background(fill, shape) .padding(horizontal = MONTH_CHIP_TEXT_PADDING) .semantics { - contentDescription = label.text + contentDescription = description if (moveAction != null) customActions = listOf(moveAction) }, contentAlignment = Alignment.CenterStart, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 434760e..cfb3c83 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List>, val weeksByIndex: Map, val weekStart: DayOfWeek, + /** As [MonthUiState.Success.zone], and for the same reason. */ + val zone: TimeZone = TimeZone.currentSystemDefault(), ) : ContinuousMonthUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index f15345a..81735d8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -238,6 +238,7 @@ class MonthViewModel @Inject constructor( monthsByIndex = months, weeksByIndex = weeks, weekStart = weekStart, + zone = zone, ) } 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 9cc9609..444278e 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 @@ -129,7 +129,9 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH +import de.jeanlucmakiola.calendula.ui.common.asEventTime import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA +import de.jeanlucmakiola.calendula.ui.common.TITLE_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay @@ -661,7 +663,7 @@ private fun AllDayBar( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill), + color = eventInk(fill, alpha = TITLE_INK_ALPHA), textDecoration = declinedDecoration(event.isDeclined), ) } @@ -916,7 +918,9 @@ private fun EventBlock( val timeMaxLines = if (showTime && spare >= timeLineHeight) { blockTextLines( text = timeLabel, - style = MaterialTheme.typography.labelSmall, + // The style it is drawn in, or the budget measures a line the label + // never uses (#219). + style = MaterialTheme.typography.labelSmall.asEventTime(), textWidth = textWidth, max = MAX_TIME_LINES, ) @@ -969,7 +973,7 @@ private fun EventBlock( title = title, maxLines = titleMaxLines, textWidth = textWidth, - color = eventInk(fill, alpha = 0.85f), + color = eventInk(fill, alpha = TITLE_INK_ALPHA), textDecoration = declinedDecoration(block.event.isDeclined), ) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyleTest.kt new file mode 100644 index 0000000..6a9a042 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyleTest.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.sp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** How an event's time is set against its title (#219). */ +class EventTimeStyleTest { + + @Test + fun `a time steps down to regular and closes its tracking up`() { + val label = TextStyle(fontWeight = FontWeight.Medium, letterSpacing = 0.5.sp) + val time = label.asEventTime() + assertThat(time.fontWeight).isEqualTo(FontWeight.Normal) + assertThat(time.letterSpacing).isEqualTo(0.sp) + } + + @Test + fun `nothing else of the style is touched`() { + val label = TextStyle( + fontSize = 11.sp, + color = Color.Red, + textDecoration = TextDecoration.LineThrough, + ) + val time = label.asEventTime() + assertThat(time.fontSize).isEqualTo(11.sp) + assertThat(time.color).isEqualTo(Color.Red) + assertThat(time.textDecoration).isEqualTo(TextDecoration.LineThrough) + } + + @Test + fun `the title is inked above the time beside it`() { + assertThat(TITLE_INK_ALPHA).isGreaterThan(SECONDARY_INK_ALPHA) + } + + @Test + fun `the time is set apart from the title it precedes`() { + val label = inlineTimeLabel("09:05", "Standup", Color.Black) + assertThat(label.text).isEqualTo("09:05 Standup") + // Only the time is restyled: the title keeps the surface's own label + // style, so the two read as separate things on one line (#219). + val spans = label.spanStyles + assertThat(spans).hasSize(1) + assertThat(spans.single().start).isEqualTo(0) + assertThat(spans.single().end).isEqualTo("09:05".length) + assertThat(spans.single().item.fontWeight).isEqualTo(FontWeight.Normal) + assertThat(spans.single().item.letterSpacing).isEqualTo(0.sp) + assertThat(spans.single().item.color).isEqualTo(Color.Black) + } + + @Test + fun `a chip without a time is styled title and nothing else`() { + val label = inlineTimeLabel(null, "Standup", Color.Black) + assertThat(label.text).isEqualTo("Standup") + assertThat(label.spanStyles).isEmpty() + } +} 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 index bccea31..cf8fd20 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt @@ -1,9 +1,6 @@ package de.jeanlucmakiola.calendula.ui.month -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import com.google.common.truth.Truth.assertThat -import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate @@ -15,7 +12,8 @@ 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). */ +/** Which month chips carry a start time, and how it reads (#219). + * How it is *set* against the title lives in `EventTimeStyleTest`. */ class MonthChipTimeTest { private val zone = TimeZone.UTC @@ -58,26 +56,6 @@ class MonthChipTimeTest { assertThat(time).isEqualTo("2:30 pm") } - @Test - fun `the time is set apart from the title it precedes`() { - val label = inlineTimeLabel("09:05", "Standup", Color.Black) - assertThat(label.text).isEqualTo("09:05 Standup") - // Only the time is restyled: the title keeps labelSmall as the theme - // sets it, so the two read as separate things on one line (#219). - val spans = label.spanStyles - assertThat(spans).hasSize(1) - assertThat(spans.single().start).isEqualTo(0) - assertThat(spans.single().end).isEqualTo("09:05".length) - assertThat(spans.single().item.fontWeight).isEqualTo(FontWeight.Normal) - } - - @Test - fun `a chip without a time is styled title and nothing else`() { - val label = inlineTimeLabel(null, "Standup", Color.Black) - assertThat(label.text).isEqualTo("Standup") - assertThat(label.spanStyles).isEmpty() - } - @Test fun `an all-day chip never shows a time`() { val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale)