Show the start time on wide month chips (#219)

This commit is contained in:
2026-09-02 21:23:07 +02:00
parent 27ce9a29c9
commit f7e9990c4e
3 changed files with 193 additions and 9 deletions

View File

@@ -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
}
}

View File

@@ -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<LocalDate>? = 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,

View File

@@ -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()
}
}