Set event times in regular weight across the timeline views (#219)

This commit is contained in:
2026-09-04 12:09:03 +02:00
parent 065afdb2b8
commit 5be8884e9f
6 changed files with 107 additions and 89 deletions

View File

@@ -172,7 +172,8 @@ fun BlockTimeLabel(
) { text -> ) { text ->
Text( Text(
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, maxLines = maxLines,
overflow = overflow.overflow, overflow = overflow.overflow,
softWrap = overflow.softWrap, softWrap = overflow.softWrap,

View File

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

View File

@@ -738,7 +738,8 @@ private fun DragCopy(
if (label != null) { if (label != null) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelSmall, // As the block it lifted off sets its own time (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,

View File

@@ -2,23 +2,11 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.domain.EventInstance 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.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale 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. */ /** 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 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 * 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 * 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) return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
} }
/** /** The narrowest chip that may show a time — the text it needs, plus its chrome. */
* 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 @Composable
internal fun rememberMonthTimeChipWidth(): Dp { internal fun rememberMonthTimeChipWidth(): Dp =
val measurer = rememberTextMeasurer() rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + CHIP_CHROME
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
}
}

View File

@@ -148,6 +148,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA 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.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape
@@ -2478,7 +2480,7 @@ private fun MonthBar(
val fill = eventFill(event.color, dark, soften) val fill = eventFill(event.color, dark, soften)
// The same title/secondary ink pairing the week and day blocks use, with // The same title/secondary ink pairing the week and day blocks use, with
// the time on the quieter half. // 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) val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
val monthDrag = LocalMonthDrag.current val monthDrag = LocalMonthDrag.current

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -59,7 +60,7 @@ class MonthChipTimeTest {
@Test @Test
fun `the time is set apart from the title it precedes`() { 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") assertThat(label.text).isEqualTo("09:05 Standup")
// Only the time is restyled: the title keeps labelSmall as the theme // 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). // sets it, so the two read as separate things on one line (#219).
@@ -72,7 +73,7 @@ class MonthChipTimeTest {
@Test @Test
fun `a chip without a time is styled title and nothing else`() { 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.text).isEqualTo("Standup")
assertThat(label.spanStyles).isEmpty() assertThat(label.spanStyles).isEmpty()
} }