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

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/266
This commit is contained in:
Jean-Luc Makiola
2026-09-04 14:15:50 +02:00
14 changed files with 399 additions and 22 deletions

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
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
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 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: 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)
/** 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 — lowercase Latin of average advance, priced at the surface's own
* text style rather than guessed in dp.
*/
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.
*/
@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

@@ -733,12 +733,13 @@ 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(
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,

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
/**
* 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 — the text it needs, plus its chrome. */
@Composable
internal fun rememberMonthTimeChipWidth(): Dp =
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME

View File

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

View File

@@ -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
@@ -146,6 +147,10 @@ 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.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
@@ -499,6 +504,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 +562,11 @@ private fun MonthDragOverlay(controller: MonthDragController) {
dark = dark,
continuesLeft = false,
continuesRight = false,
// 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
// offset would mirror them across the screen in an RTL layout.
@@ -568,7 +579,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 +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
private 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
@@ -807,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,
@@ -850,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(),
@@ -871,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,
@@ -891,6 +909,8 @@ private fun ContinuousMonthBlock(
weeks: List<MonthWeek>?,
weekStart: DayOfWeek,
today: LocalDate,
zone: TimeZone,
timeChipWidth: Dp,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
@@ -910,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.
@@ -979,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
@@ -1001,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 },
@@ -1886,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,
@@ -1905,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
@@ -1979,9 +2029,12 @@ 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
// Per-day background pills — same surfaceContainer rounded surface the
@@ -2062,6 +2115,8 @@ 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(
x = colW * span.startCol,
@@ -2085,7 +2140,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 +2164,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 +2187,8 @@ private fun MonthWeekRow(
continuesLeft = false,
continuesRight = false,
days = listOf(d),
time = chipTimes[ev.instanceId],
showTime = colW >= timeChipWidth,
modifier = Modifier
.offset(
x = colW * col,
@@ -2140,7 +2197,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
@@ -2292,6 +2349,9 @@ private fun monthChipDragModifier(
band: Array<LayoutCoordinates?>,
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<Long, String?>,
): Modifier = rememberDragSurface(
enabled = moveScope?.dragEnabled == true && controller != null,
key = week.days.first(),
@@ -2320,6 +2380,7 @@ private fun monthChipDragModifier(
y = requireNotNull(bandTop) + lane * rowHeightPx,
),
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
time = chipTimes[event.instanceId],
)
true
}
@@ -2427,7 +2488,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,12 +2505,27 @@ private fun MonthBar(
* the provider hands the re-read instance a new one.
*/
days: List<LocalDate>? = null,
/** 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 title = event.title.ifBlank { stringResource(R.string.event_untitled) }
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 = 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
@@ -2458,21 +2537,21 @@ 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 = description
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,
softWrap = titleOverflow.softWrap,
color = eventInk(fill),
color = eventInk(fill, alpha = TITLE_INK_ALPHA),
textDecoration = declinedDecoration(event.isDeclined),
)
}

View File

@@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
*/
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
spanAt(col, lane)?.let { return it.event }
val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane }
@@ -91,8 +91,11 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
* [col] for a single-day chip, and for an empty slot no one should be asking
* about.
*/
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.startCol ?: col
/** The bar covering lane [lane] of column [col], or null where a pill sits there. */
fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
/**
* The events on [day] that [laneEvents] had no lane left for — its exact
@@ -136,6 +139,8 @@ sealed interface ContinuousMonthUiState {
val monthsByIndex: Map<Int, List<MonthWeek>>,
val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek,
/** As [MonthUiState.Success.zone], and for the same reason. */
val zone: TimeZone = TimeZone.currentSystemDefault(),
) : ContinuousMonthUiState
}

View File

@@ -238,6 +238,7 @@ class MonthViewModel @Inject constructor(
monthsByIndex = months,
weeksByIndex = weeks,
weekStart = weekStart,
zone = zone,
)
}

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
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).
* How it is *set* against the title lives in `EventTimeStyleTest`. */
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()
}
}