Hold the time apart from the title on any font (#219)

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.
This commit is contained in:
2026-09-04 14:07:55 +02:00
parent 5be8884e9f
commit 72be175fca
13 changed files with 210 additions and 99 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 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 cell, which is often the difference between two events you can tell apart and
two you can't ([#164]). 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 - 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 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 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 [#225]: https://codeberg.org/jlmakiola/calendula/issues/225
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228 [#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234 [#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248 [#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253 [#253]: https://codeberg.org/jlmakiola/calendula/issues/253

View File

@@ -15,32 +15,26 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.floret.locale.currentLocale 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_WEIGHT = FontWeight.Normal
private val TIME_TRACKING = 0.sp 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 = fun TextStyle.asEventTime(): TextStyle =
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING) 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]. */ /** Text reading [time], set apart in [timeInk], before [title]. */
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString = fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
buildAnnotatedString { 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 * 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 * its place — lowercase Latin of average advance, priced at the surface's own
* characters and priced at the surface's own text style rather than guessed in * text style rather than guessed in dp.
* 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" 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 * 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 * 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 * already been taken off the width.
* tablet all come out wide enough without any of them being named.
*/ */
@Composable @Composable
fun rememberInlineTimeWidth(style: TextStyle): Dp { fun rememberInlineTimeWidth(style: TextStyle): Dp {

View File

@@ -733,7 +733,7 @@ private fun DragCopy(
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = TITLE_INK_ALPHA),
) )
if (label != null) { if (label != null) {
Text( Text(

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.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION 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.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.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -526,7 +528,7 @@ private fun AllDayBar(
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
color = eventInk(fill), color = eventInk(fill, alpha = TITLE_INK_ALPHA),
textDecoration = declinedDecoration(event.isDeclined), textDecoration = declinedDecoration(event.isDeclined),
) )
} }
@@ -749,7 +751,9 @@ private fun EventBlock(
val timeMaxLines = if (showTime && spare >= timeLineHeight) { val timeMaxLines = if (showTime && spare >= timeLineHeight) {
blockTextLines( blockTextLines(
text = timeLabel, 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, textWidth = textWidth,
max = MAX_TIME_LINES, max = MAX_TIME_LINES,
) )
@@ -800,7 +804,7 @@ private fun EventBlock(
title = title, title = title,
maxLines = titleMaxLines, maxLines = titleMaxLines,
textWidth = textWidth, textWidth = textWidth,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = TITLE_INK_ALPHA),
textDecoration = declinedDecoration(block.event.isDeclined), 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

@@ -3,7 +3,6 @@ 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.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
@@ -11,15 +10,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale 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 * 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
@@ -40,4 +30,4 @@ internal fun monthChipTime(
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */ /** The narrowest chip that may show a time — the text it needs, plus its chrome. */
@Composable @Composable
internal fun rememberMonthTimeChipWidth(): Dp = internal fun rememberMonthTimeChipWidth(): Dp =
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + CHIP_CHROME rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME

View File

@@ -97,6 +97,13 @@ data class MonthChipDrag(
val targetDate: LocalDate?, val targetDate: LocalDate?,
val topLeftInRoot: Offset, val topLeftInRoot: Offset,
val sizePx: IntSize, 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. */ /** 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 event: EventInstance? = null
private var grabDate: LocalDate? = null private var grabDate: LocalDate? = null
private var time: String? = null
private var grab = Offset.Zero private var grab = Offset.Zero
private var pointer = Offset.Zero private var pointer = Offset.Zero
private var sizePx = IntSize.Zero private var sizePx = IntSize.Zero
@@ -190,9 +198,11 @@ class MonthDragController {
pointerInRoot: Offset, pointerInRoot: Offset,
chipInRoot: Offset, chipInRoot: Offset,
size: IntSize, size: IntSize,
time: String? = null,
) { ) {
this.event = event this.event = event
this.grabDate = grabDate this.grabDate = grabDate
this.time = time
settling = null settling = null
isDragging = true isDragging = true
liftedInstanceId = event.instanceId liftedInstanceId = event.instanceId
@@ -210,6 +220,7 @@ class MonthDragController {
fun cancel() { fun cancel() {
event = null event = null
grabDate = null grabDate = null
time = null
isDragging = false isDragging = false
liftedInstanceId = null liftedInstanceId = null
drag = null drag = null
@@ -331,6 +342,7 @@ class MonthDragController {
targetDate = resolved ?: drag?.targetDate, targetDate = resolved ?: drag?.targetDate,
topLeftInRoot = pointer - grab, topLeftInRoot = pointer - grab,
sizePx = sizePx, 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -561,8 +562,10 @@ private fun MonthDragOverlay(controller: MonthDragController) {
dark = dark, dark = dark,
continuesLeft = false, continuesLeft = false,
continuesRight = false, continuesRight = false,
// The copy is a cut-out of the chip it lifted off, so it answers the // The time the source chip carried, drawn only if this one-column
// gate at the same width the grid did. // 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, showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
modifier = Modifier modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware // 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 WEEK_NUMBER_GUTTER = 40.dp
private val DAY_NUMBER_GAP = 4.dp private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.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 /** Named separately because the split style's selection outline draws its own
* rounded rect and has to match this radius exactly. */ * rounded rect and has to match this radius exactly. */
private val CELL_CORNER = 12.dp private val CELL_CORNER = 12.dp
@@ -815,10 +817,15 @@ internal fun MonthGrid(
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
val month = state.month 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 -> state.weeks.forEach { week ->
MonthWeekRow( MonthWeekRow(
week = week, week = week,
today = state.today, today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -858,6 +865,7 @@ internal fun ContinuousMonthGrid(
) { ) {
val monthCount = remember { continuousMonthCount() } val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) } val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
@@ -879,6 +887,8 @@ internal fun ContinuousMonthGrid(
weeks = state.monthsByIndex[index], weeks = state.monthsByIndex[index],
weekStart = state.weekStart, weekStart = state.weekStart,
today = state.today, today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -899,6 +909,8 @@ private fun ContinuousMonthBlock(
weeks: List<MonthWeek>?, weeks: List<MonthWeek>?,
weekStart: DayOfWeek, weekStart: DayOfWeek,
today: LocalDate, today: LocalDate,
zone: TimeZone,
timeChipWidth: Dp,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -918,6 +930,8 @@ private fun ContinuousMonthBlock(
MonthWeekRow( MonthWeekRow(
week = week, week = week,
today = today, today = today,
zone = zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
// The block owns its month alone: a day from either // The block owns its month alone: a day from either
// neighbour is left out entirely rather than dimmed. // neighbour is left out entirely rather than dimmed.
@@ -987,6 +1001,7 @@ internal fun DenseMonthGrid(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier modifier = modifier
@@ -1009,6 +1024,8 @@ internal fun DenseMonthGrid(
MonthWeekRow( MonthWeekRow(
week = week, week = week,
today = state.today, today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
// Every day in the stream belongs to a month equally — there // Every day in the stream belongs to a month equally — there
// is no "other month" to recede here. // is no "other month" to recede here.
inMonth = { true }, inMonth = { true },
@@ -1894,6 +1911,10 @@ private fun rememberSkeletonPulse(): Float {
private fun MonthWeekRow( private fun MonthWeekRow(
week: MonthWeek, week: MonthWeek,
today: LocalDate, 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, inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -1913,6 +1934,27 @@ private fun MonthWeekRow(
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val morphing = morphInFlight() 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 // 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 // — the full-bleed tap layer sits on top of them — so one detector on the
@@ -1987,14 +2029,13 @@ private fun MonthWeekRow(
band = bandCoordinates, band = bandCoordinates,
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
isRtl = isRtl, isRtl = isRtl,
chipTimes = chipTimes,
), ),
), ),
) { ) {
// What a chip has to spend, against the [timeChipWidth] a start time
// costs it (#219).
val colW = maxWidth / 7 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 // Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language. // week/day views use, so the three views share one visual language.
@@ -2074,6 +2115,7 @@ private fun MonthWeekRow(
continuesLeft = span.continuesLeft, continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight, continuesRight = span.continuesRight,
days = week.days.subList(span.startCol, span.endCol + 1), days = week.days.subList(span.startCol, span.endCol + 1),
time = chipTimes[span.event.instanceId],
showTime = colW * cols >= timeChipWidth, showTime = colW * cols >= timeChipWidth,
modifier = Modifier modifier = Modifier
.offset( .offset(
@@ -2145,6 +2187,7 @@ private fun MonthWeekRow(
continuesLeft = false, continuesLeft = false,
continuesRight = false, continuesRight = false,
days = listOf(d), days = listOf(d),
time = chipTimes[ev.instanceId],
showTime = colW >= timeChipWidth, showTime = colW >= timeChipWidth,
modifier = Modifier modifier = Modifier
.offset( .offset(
@@ -2306,6 +2349,9 @@ private fun monthChipDragModifier(
band: Array<LayoutCoordinates?>, band: Array<LayoutCoordinates?>,
rowHeightPx: Float, rowHeightPx: Float,
isRtl: Boolean, 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( ): Modifier = rememberDragSurface(
enabled = moveScope?.dragEnabled == true && controller != null, enabled = moveScope?.dragEnabled == true && controller != null,
key = week.days.first(), key = week.days.first(),
@@ -2334,6 +2380,7 @@ private fun monthChipDragModifier(
y = requireNotNull(bandTop) + lane * rowHeightPx, y = requireNotNull(bandTop) + lane * rowHeightPx,
), ),
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()), size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
time = chipTimes[event.instanceId],
) )
true true
} }
@@ -2458,21 +2505,11 @@ private fun MonthBar(
* the provider hands the re-read instance a new one. * the provider hands the re-read instance a new one.
*/ */
days: List<LocalDate>? = null, days: List<LocalDate>? = 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, 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 title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
@@ -2480,7 +2517,15 @@ 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 = 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) 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
@@ -2494,7 +2539,7 @@ private fun MonthBar(
.background(fill, shape) .background(fill, shape)
.padding(horizontal = MONTH_CHIP_TEXT_PADDING) .padding(horizontal = MONTH_CHIP_TEXT_PADDING)
.semantics { .semantics {
contentDescription = label.text contentDescription = description
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
}, },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,

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? { fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null 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 val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol } .filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane } .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 * [col] for a single-day chip, and for an empty slot no one should be asking
* about. * about.
*/ */
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.startCol ?: col
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.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 * 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 monthsByIndex: Map<Int, List<MonthWeek>>,
val weeksByIndex: Map<Int, MonthWeek>, val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
/** As [MonthUiState.Success.zone], and for the same reason. */
val zone: TimeZone = TimeZone.currentSystemDefault(),
) : ContinuousMonthUiState ) : ContinuousMonthUiState
} }

View File

@@ -238,6 +238,7 @@ class MonthViewModel @Inject constructor(
monthsByIndex = months, monthsByIndex = months,
weeksByIndex = weeks, weeksByIndex = weeks,
weekStart = weekStart, 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.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION 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.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.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.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -661,7 +663,7 @@ private fun AllDayBar(
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
color = eventInk(fill), color = eventInk(fill, alpha = TITLE_INK_ALPHA),
textDecoration = declinedDecoration(event.isDeclined), textDecoration = declinedDecoration(event.isDeclined),
) )
} }
@@ -916,7 +918,9 @@ private fun EventBlock(
val timeMaxLines = if (showTime && spare >= timeLineHeight) { val timeMaxLines = if (showTime && spare >= timeLineHeight) {
blockTextLines( blockTextLines(
text = timeLabel, 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, textWidth = textWidth,
max = MAX_TIME_LINES, max = MAX_TIME_LINES,
) )
@@ -969,7 +973,7 @@ private fun EventBlock(
title = title, title = title,
maxLines = titleMaxLines, maxLines = titleMaxLines,
textWidth = textWidth, textWidth = textWidth,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = TITLE_INK_ALPHA),
textDecoration = declinedDecoration(block.event.isDeclined), 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

@@ -1,9 +1,6 @@
package de.jeanlucmakiola.calendula.ui.month 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 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
@@ -15,7 +12,8 @@ import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.util.Locale 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 { class MonthChipTimeTest {
private val zone = TimeZone.UTC private val zone = TimeZone.UTC
@@ -58,26 +56,6 @@ class MonthChipTimeTest {
assertThat(time).isEqualTo("2:30 pm") 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 @Test
fun `an all-day chip never shows a time`() { fun `an all-day chip never shows a time`() {
val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale) val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale)