Compare commits

..
Author SHA1 Message Date
makiolaj 63dbe46a88 Seat as many month chips as the row has height for 2026-09-23 17:06:47 +02:00
5 changed files with 290 additions and 65 deletions
@@ -83,10 +83,8 @@ fun calendarSlideTransition(
initialContentExit = initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec), slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which // AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. Left off even // shears the pages against the viewport edge as they pass. There is no
// where the two pages differ in height — the split grid stands as many // size change here to contain — both pages are the same grid.
// rows as its month spans (#162) — since a page sliding out over the row
// below it reads as travel, and the shear reads as a fault.
sizeTransform = SizeTransform(clip = false), sizeTransform = SizeTransform(clip = false),
) )
} }
@@ -123,6 +123,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.rememberTextMeasurer
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
@@ -774,14 +775,79 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
/** Width of the split style's selected-day outline. */ /** Width of the split style's selected-day outline. */
private val SPLIT_SELECTION_STROKE = 1.5.dp private val SPLIT_SELECTION_STROKE = 1.5.dp
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */ /**
internal const val MAX_EVENT_ROWS = 3 * Dots a split-style cell draws. Fixed, unlike the paged grid's measured cap:
* the split rows are a fixed height whatever the screen is.
*
* Dot *i* is lane *i*, so a dot morphs into the bar the expanded grid draws
* there (#53) — [MonthWeek.laneEvents] seats the same events in the same order
* at any cap, so a larger one only appends. Where the expanded grid seats fewer
* lanes than this — a six-row month in a short landscape viewport — the dots
* past its cap have no bar to become and simply fade instead of travelling.
*/
internal const val SPLIT_DOT_LANES = 3
/** Diameter of a single overflow dot. */
private val OVERFLOW_DOT_SIZE = 6.dp
/**
* Height of the overflow row: the "+N" beside the dots, measured at the style it
* is drawn in.
*
* It was taking a whole event lane, which is more than a dot and a label line
* need and one lane fewer for the chips. Measured rather than picked, so it
* still holds the label at a large font scale — a fixed height clipped the "+N"
* at anything above the default.
*/
@Composable
private fun rememberOverflowRowHeight(): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = MaterialTheme.typography.labelSmall
return remember(style, density, measurer) {
with(density) { measurer.measure(OVERFLOW_SAMPLE, style).size.height.toDp() }
.coerceAtLeast(OVERFLOW_DOT_SIZE)
}
}
/** The tallest the counter gets; digits are tabular, so one is as wide as any. */
private const val OVERFLOW_SAMPLE = "+9"
/**
* Lanes of chips this week's cells draw in a row [rowHeight] tall, before the
* rest become dots.
*
* The cap used to be a flat three at every size, so a tall five-row month threw
* away lanes it had the room for while a cramped six-row one drew a third chip
* its band could not hold and put the dots below the clip, where nothing showed
* that the day held more at all.
*
* Measured instead, with no ceiling: a cell spends whatever height it was given.
* The overflow row is only charged for when some day in the week actually
* overflows — a week that fits gets that space as another lane rather than
* reserving room for a marker it will not draw.
*/
internal fun MonthWeek.laneCapFor(rowHeight: Dp, overflowRow: Dp): Int {
val band = monthBandHeight(rowHeight)
val full = (band / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
if (!overflowsAt(full)) return full
return ((band - overflowRow) / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
}
/** What a row [rowHeight] tall leaves for chips once the day number is drawn. */
internal fun monthBandHeight(rowHeight: Dp): Dp =
rowHeight - CELL_TOP_PADDING - DAY_NUMBER_HEIGHT - DAY_NUMBER_GAP
/** Whether any day in this week holds more than [lanes] lanes can seat. */
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
/** /**
* Row height in the continuous grid. The paged grid divides the viewport between * Row height in the continuous grid. The paged grid divides the viewport between
* however many rows the month has; a scrolling stream has no such bound, so it * however many rows the month has; a scrolling stream has no such bound, so it
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows — * fixes one — close to what a five-row month gets on a typical phone. How many
* close to what a five-row month gets on a typical phone. * chips that seats is [MonthWeek.laneCapFor]'s answer like anywhere else, so a
* week that does not overflow fills the band rather than holding a lane back.
*/ */
private val CONTINUOUS_ROW_HEIGHT = 112.dp private val CONTINUOUS_ROW_HEIGHT = 112.dp
@@ -793,6 +859,11 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
*/ */
private val CONTINUOUS_MONTH_GAP = 20.dp private val CONTINUOUS_MONTH_GAP = 20.dp
/** The paged grid's own vertical padding, and the gap between its week rows —
* named because [monthLaneCap] has to take them off the viewport first. */
private val GRID_VERTICAL_PADDING = 4.dp
private val GRID_ROW_GAP = 2.dp
/** Gap between the weekday header and the seamless stream's first week row. */ /** Gap between the weekday header and the seamless stream's first week row. */
private val DENSE_HEADER_GAP = 4.dp private val DENSE_HEADER_GAP = 4.dp
@@ -805,34 +876,43 @@ internal fun MonthGrid(
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
selected: LocalDate? = null, selected: LocalDate? = null,
) { ) {
Column( BoxWithConstraints(Modifier.fillMaxSize()) {
modifier = Modifier // The rows divide whatever the viewport leaves once the Column's own
.fillMaxSize() // padding and the gaps between them are paid, so how many chips a cell
// Match the weekday header's inset so day cells sit under their // can seat is only knowable here.
// labels, and so the week-number gutter's centre lines up with the val rows = state.weeks.size.coerceAtLeast(1)
// top bar's hamburger (4dp bar inset + 24dp half icon button). val rowHeight =
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), (maxHeight - GRID_VERTICAL_PADDING * 2 - GRID_ROW_GAP * (rows - 1)) / rows
verticalArrangement = Arrangement.spacedBy(2.dp), Column(
) { modifier = Modifier
val month = state.month .fillMaxSize()
// Once per grid: the value depends on the typography, the density, the // Match the weekday header's inset so day cells sit under their
// locale and the 24-hour setting, none of which vary by row (#219). // labels, and so the week-number gutter's centre lines up with the
val timeChipWidth = rememberMonthTimeChipWidth() // top bar's hamburger (4dp bar inset + 24dp half icon button).
state.weeks.forEach { week -> .padding(horizontal = AppBarSpacing.Inset, vertical = GRID_VERTICAL_PADDING),
MonthWeekRow( verticalArrangement = Arrangement.spacedBy(GRID_ROW_GAP),
week = week, ) {
today = state.today, val month = state.month
zone = state.zone, // Once per grid: the value depends on the typography, the density, the
timeChipWidth = timeChipWidth, // locale and the 24-hour setting, none of which vary by row (#219).
inMonth = { it.month == month.month && it.year == month.year }, val timeChipWidth = rememberMonthTimeChipWidth()
showWeekNumbers = showWeekNumbers, state.weeks.forEach { week ->
onOpenDay = onOpenDay, MonthWeekRow(
onEventClick = onEventClick, week = week,
selected = selected, today = state.today,
modifier = Modifier zone = state.zone,
.fillMaxWidth() timeChipWidth = timeChipWidth,
.weight(1f), rowHeight = rowHeight,
) inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
selected = selected,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
}
} }
} }
} }
@@ -930,6 +1010,7 @@ private fun ContinuousMonthBlock(
today = today, today = today,
zone = zone, zone = zone,
timeChipWidth = timeChipWidth, timeChipWidth = timeChipWidth,
rowHeight = CONTINUOUS_ROW_HEIGHT,
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.
@@ -1024,6 +1105,7 @@ internal fun DenseMonthGrid(
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
timeChipWidth = timeChipWidth, timeChipWidth = timeChipWidth,
rowHeight = CONTINUOUS_ROW_HEIGHT,
// 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 },
@@ -1141,10 +1223,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
* lists whatever day is selected — and a downward drag trades the pane away for * lists whatever day is selected — and a downward drag trades the pane away for
* the full paged grid, an upward one brings it back (#53). * the full paged grid, an upward one brings it back (#53).
* *
* The grid slides between months like the paged style, and stands only as many * The grid slides between months like the paged style, which it can only do
* rows tall as its own month spans (#162). A swipe between a five-row month and * because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
* a six-row one therefore moves the pane by a row as well as swapping the grid; * stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on
* the row it hands back is worth more to the pane than a still edge is. * top of swapping the grid — the pane now holds still and only the grid moves.
* *
* Expansion is deliberately **not** a stored preference. It is a way to look at * Expansion is deliberately **not** a stored preference. It is a way to look at
* the month you are on, not a fourth style; persisted, someone would expand it * the month you are on, not a fourth style; persisted, someone would expand it
@@ -1437,10 +1519,17 @@ private fun SplitExpandHandle(
*/ */
private val SPLIT_ROW_HEIGHT = 46.dp private val SPLIT_ROW_HEIGHT = 46.dp
private val SPLIT_DOT_SIZE = 5.dp private val SPLIT_DOT_SIZE = 5.dp
// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for // Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for
// the paged grid's lanes, so the two caps have to be the same number or a dot // the paged grid's lanes, so the two caps have to be the same number or a dot
// would have no bar to become (#53). // would have no bar to become (#53).
/**
* Rows the split grid always reserves — the most any month needs. A month that
* fits in fewer pads the remainder with blank rows rather than shrinking, which
* is what lets the pane below hold still from month to month.
*/
private const val SPLIT_GRID_ROWS = 6
/** /**
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a * The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
* comfortable tap target on its own. * comfortable tap target on its own.
@@ -1453,11 +1542,6 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
* The split style's grid (#53): the month compressed to day numbers and event * The split style's grid (#53): the month compressed to day numbers and event
* dots, with the selected day listed underneath by [SplitDayPane]. * dots, with the selected day listed underneath by [SplitDayPane].
* *
* Only the rows the month actually spans. It used to pad every month out to six
* so the pane below held still from page to page, but a row is a sixth of the
* grid and a third of what the pane gets to show — too much to leave blank on
* the months that don't need it (#162).
*
* Tapping selects rather than drilling into the Day view — the pane is the * Tapping selects rather than drilling into the Day view — the pane is the
* answer to "what's on this day", so opening a whole screen for it would defeat * answer to "what's on this day", so opening a whole screen for it would defeat
* the layout. The full Day view stays one tap away on the pane's date header. * the layout. The full Day view stays one tap away on the pane's date header.
@@ -1492,11 +1576,11 @@ internal fun SplitMonthGrid(
val inMonth = day.month == month.month && day.year == month.year val inMonth = day.month == month.month && day.year == month.year
// Seated by lane rather than gathered by colour, so each dot // Seated by lane rather than gathered by colour, so each dot
// is the event the expanded grid draws in that same lane. // is the event the expanded grid draws in that same lane.
val seated = week.laneEvents(col, day, MAX_EVENT_ROWS) val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
SplitDayCell( SplitDayCell(
date = day, date = day,
events = seated, events = seated,
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS), hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
isToday = day == state.today, isToday = day == state.today,
// A page marks only the days its own month owns. Paging // A page marks only the days its own month owns. Paging
// moves the selection before this month's replacement // moves the selection before this month's replacement
@@ -1516,11 +1600,17 @@ internal fun SplitMonthGrid(
} }
} }
} }
// Hold the grid at a constant height whatever shape the month is, so the
// pane beneath it doesn't move as you page and one month can slide over
// another without a height change under it.
repeat(SPLIT_GRID_ROWS - state.weeks.size) {
Spacer(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT))
}
} }
} }
/** /**
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots. * One compact day: its number over up to [SPLIT_DOT_LANES] lane-seated event dots.
* *
* Selection and today are deliberately different signals — a tinted, outlined * Selection and today are deliberately different signals — a tinted, outlined
* cell versus the filled circle the other views already use for today — so the * cell versus the filled circle the other views already use for today — so the
@@ -1894,7 +1984,7 @@ private fun rememberSkeletonPulse(): Float {
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
* a multi-day event is one connected bar across the columns; single-day timed * a multi-day event is one connected bar across the columns; single-day timed
* events sit beneath them as filled pills in their own cell. The cap is * events sit beneath them as filled pills in their own cell. The cap is
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day. * [SPLIT_DOT_LANES] rows of bars+pills, then a "+N" dot indicator per day.
* A transparent per-day layer on top turns a tap into "open that day". * A transparent per-day layer on top turns a tap into "open that day".
*/ */
@Composable @Composable
@@ -1905,6 +1995,8 @@ private fun MonthWeekRow(
zone: TimeZone, zone: TimeZone,
/** The narrowest chip that may carry a start time, measured once per grid (#219). */ /** The narrowest chip that may carry a start time, measured once per grid (#219). */
timeChipWidth: Dp, timeChipWidth: Dp,
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
rowHeight: Dp,
inMonth: (LocalDate) -> Boolean, inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -1921,8 +2013,10 @@ private fun MonthWeekRow(
selected: LocalDate? = null, selected: LocalDate? = null,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val overflowRow = rememberOverflowRowHeight()
val laneCap = week.laneCapFor(rowHeight, overflowRow)
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(laneCap)
val morphing = morphInFlight() val morphing = morphInFlight()
// Every chip's start time for this row at once, and only when the row's own // 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 // inputs change: formatting is a parsed pattern per call, and the dim cutoff
@@ -1975,9 +2069,9 @@ private fun MonthWeekRow(
band = bandCoordinates[0], band = bandCoordinates[0],
columnWidthPx = cell.size.width / 7f, columnWidthPx = cell.size.width / 7f,
laneHeightPx = rowHeightPx, laneHeightPx = rowHeightPx,
laneCount = MAX_EVENT_ROWS, laneCount = laneCap,
isRtl = isRtl, isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) }, chipAt = { col, lane -> week.chipAt(col, lane, laneCap) },
chipStart = { col, lane -> week.chipStartCol(col, lane) }, chipStart = { col, lane -> week.chipStartCol(col, lane) },
), ),
) )
@@ -2018,6 +2112,7 @@ private fun MonthWeekRow(
controller = dragController, controller = dragController,
band = bandCoordinates, band = bandCoordinates,
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
laneCap = laneCap,
isRtl = isRtl, isRtl = isRtl,
chipTimes = chipTimes, chipTimes = chipTimes,
), ),
@@ -2026,6 +2121,11 @@ private fun MonthWeekRow(
// What a chip has to spend, against the [timeChipWidth] a start time // What a chip has to spend, against the [timeChipWidth] a start time
// costs it (#219). // costs it (#219).
val colW = maxWidth / 7 val colW = maxWidth / 7
// Held here rather than read at the offset: the dots are placed
// inside a plain lambda, which is no longer in this scope. The box
// is the whole row, so the day number's share comes off it — the
// dots are positioned inside the band, not inside this.
val bandHeight = monthBandHeight(maxHeight)
// 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.
@@ -2168,7 +2268,7 @@ private fun MonthWeekRow(
.filter { it.lane < shownLanes && col in it.startCol..it.endCol } .filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane } .map { it.lane }
.toSet() .toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied } val freeSlots = (0 until laneCap).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size) val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev -> pillsShown.forEachIndexed { i, ev ->
MonthBar( MonthBar(
@@ -2202,8 +2302,20 @@ private fun MonthWeekRow(
events = hiddenEvents, events = hiddenEvents,
total = hidden, total = hidden,
dark = dark, dark = dark,
rowHeight = overflowRow,
modifier = Modifier modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) // After the chip lanes, but never past the
// band: on a row too short for the lanes it
// is holding, dots placed below it are
// clipped away entirely and the day looks
// like it has nothing more to show.
.offset(
x = colW * col,
y = minOf(
EVENT_ROW_HEIGHT * laneCap,
bandHeight - overflowRow,
),
)
.morphBounds(MonthMorphKey.Overflow(d)) .morphBounds(MonthMorphKey.Overflow(d))
.width(colW) .width(colW)
.padding(horizontal = 3.dp), .padding(horizontal = 3.dp),
@@ -2278,6 +2390,7 @@ private fun MonthWeekRow(
bandCoordinates, bandCoordinates,
), ),
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
laneCap = laneCap,
) )
if (chip != null) onEventClick(chip) else onOpenDay(d) if (chip != null) onEventClick(chip) else onOpenDay(d)
}, },
@@ -2319,11 +2432,12 @@ internal fun MonthWeek.chipAtCellY(
cellY: Float, cellY: Float,
bandTopInCell: Float?, bandTopInCell: Float?,
rowHeightPx: Float, rowHeightPx: Float,
laneCap: Int,
): EventInstance? { ): EventInstance? {
if (bandTopInCell == null || rowHeightPx <= 0f) return null if (bandTopInCell == null || rowHeightPx <= 0f) return null
val bandY = cellY - bandTopInCell val bandY = cellY - bandTopInCell
if (bandY < 0f) return null if (bandY < 0f) return null
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS) return chipAt(col, (bandY / rowHeightPx).toInt(), laneCap)
} }
/** /**
@@ -2338,6 +2452,7 @@ private fun monthChipDragModifier(
controller: MonthDragController?, controller: MonthDragController?,
band: Array<LayoutCoordinates?>, band: Array<LayoutCoordinates?>,
rowHeightPx: Float, rowHeightPx: Float,
laneCap: Int,
isRtl: Boolean, isRtl: Boolean,
/** The row's formatted chip times by instance id, so the copy carries the /** The row's formatted chip times by instance id, so the copy carries the
* one its source chip had rather than deriving another (#219). */ * one its source chip had rather than deriving another (#219). */
@@ -2356,7 +2471,7 @@ private fun monthChipDragModifier(
val event = if (bandY < 0f || columnPx <= 0f) { val event = if (bandY < 0f || columnPx <= 0f) {
null null
} else { } else {
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS) week.chipAt(dayIndex, lane, laneCap)
} }
if (event == null || moveScope?.allows(event) != true) { if (event == null || moveScope?.allows(event) != true) {
false false
@@ -2557,6 +2672,7 @@ private fun OverflowDots(
events: List<EventInstance>, events: List<EventInstance>,
total: Int, total: Int,
dark: Boolean, dark: Boolean,
rowHeight: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
@@ -2564,14 +2680,14 @@ private fun OverflowDots(
val byColor = events.groupBy { it.color } val byColor = events.groupBy { it.color }
val dots = byColor.keys.take(3) val dots = byColor.keys.take(3)
Row( Row(
modifier = modifier.height(EVENT_ROW_HEIGHT), modifier = modifier.height(rowHeight),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
dots.forEach { argb -> dots.forEach { argb ->
Box( Box(
modifier = Modifier modifier = Modifier
.size(6.dp) .size(OVERFLOW_DOT_SIZE)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f) .alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.background(eventAccent(argb, dark, soften), CircleShape), .background(eventAccent(argb, dark, soften), CircleShape),
) )
@@ -167,7 +167,7 @@ internal fun layoutAllDay(
// in non-decreasing start order, which the declined-last rule above breaks: // in non-decreasing start order, which the declined-last rule above breaks:
// a Monday bar seated after a Wednesday one would be refused a lane it is // a Monday bar seated after a Wednesday one would be refused a lane it is
// nowhere near, and each wasted lane costs the all-day strip a whole row and // nowhere near, and each wasted lane costs the all-day strip a whole row and
// pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns // pushes a bar closer to the month grid's lane cap. Seven columns
// and a handful of bars, so the scan is cheaper than the sort above it. // and a handful of bars, so the scan is cheaper than the sort above it.
val laneCols = ArrayList<MutableList<IntRange>>() val laneCols = ArrayList<MutableList<IntRange>>()
return raw.map { r -> return raw.map { r ->
@@ -56,7 +56,13 @@ class ChipAtCellYTest {
) )
private fun MonthWeek.chipAt(col: Int, cellY: Float) = private fun MonthWeek.chipAt(col: Int, cellY: Float) =
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight) chipAtCellY(
col = col,
cellY = cellY,
bandTopInCell = bandTop,
rowHeightPx = laneHeight,
laneCap = SPLIT_DOT_LANES,
)
@Test @Test
fun `a tap on a lane resolves to the chip seated there`() { fun `a tap on a lane resolves to the chip seated there`() {
@@ -98,13 +104,13 @@ class ChipAtCellYTest {
@Test @Test
fun `a tap on the overflow row opens the day rather than a hidden event`() { fun `a tap on the overflow row opens the day rather than a hidden event`() {
val events = (1..MAX_EVENT_ROWS + 2).map { val events = (1..SPLIT_DOT_LANES + 2).map {
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong()) timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
} }
val week = rowOfJuly6(events) val week = rowOfJuly6(events)
// The dots sit one lane below the last one the row draws. // The dots sit one lane below the last one the row draws.
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f val overflowY = bandTop + laneHeight * SPLIT_DOT_LANES + 2f
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull() assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
} }
@@ -113,10 +119,22 @@ class ChipAtCellYTest {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L))) val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat( assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight), week.chipAtCellY(
col = 1,
cellY = 45f,
bandTopInCell = null,
rowHeightPx = laneHeight,
laneCap = SPLIT_DOT_LANES,
),
).isNull() ).isNull()
assertThat( assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f), week.chipAtCellY(
col = 1,
cellY = 45f,
bandTopInCell = bandTop,
rowHeightPx = 0f,
laneCap = SPLIT_DOT_LANES,
),
).isNull() ).isNull()
} }
} }
@@ -0,0 +1,93 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* How many chip lanes a week row seats at a given height (#190) — the cap that
* used to be a flat three whatever the device had.
*/
class MonthLaneCapTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
private val monday = LocalDate(2026, 7, 6)
/** Cell chrome above the band: 6 + 22 + 4. */
private val header = 32.dp
private fun timed(day: LocalDate, hour: Int, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = day.atTime(hour, 0).toInstant(zone),
end = day.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFFF44336.toInt(),
location = null,
)
/** Jul 6-12, wholly inside July 2026. */
private fun week(eventsOnMonday: Int) = layoutMonthWeeks(
jul26,
DayOfWeek.MONDAY,
(1..eventsOnMonday).map { timed(monday, hour = it, id = it.toLong()) },
zone,
)[1]
private fun rowFor(bandHeight: Int) = header + bandHeight.dp
/** A labelSmall line at font scale 1 — what the overflow row measures to. */
private val overflowRow = 16.dp
private fun MonthWeek.capAt(rowHeight: androidx.compose.ui.unit.Dp) =
laneCapFor(rowHeight, overflowRow)
@Test
fun `a week that fits spends the overflow row on another lane`() {
// 80dp of band is four 20dp lanes. Nothing overflows at four, so no
// room is set aside for a marker that would never be drawn.
assertThat(week(eventsOnMonday = 4).capAt(rowFor(80))).isEqualTo(4)
}
@Test
fun `a week that overflows pays for the dots out of its own lanes`() {
// The same 80dp, but a fifth event means the dots have to be drawn, and
// their 14dp comes off the band before it is divided.
assertThat(week(eventsOnMonday = 5).capAt(rowFor(80))).isEqualTo(3)
}
@Test
fun `a taller row seats more, with no ceiling`() {
assertThat(week(eventsOnMonday = 20).capAt(rowFor(120))).isEqualTo(5)
assertThat(week(eventsOnMonday = 20).capAt(rowFor(220))).isEqualTo(10)
}
@Test
fun `a cramped row seats fewer rather than drawing past its band`() {
// 58dp was three clipped lanes and dots nobody could see. It is two
// lanes and a visible marker.
assertThat(week(eventsOnMonday = 6).capAt(rowFor(58))).isEqualTo(2)
}
@Test
fun `a row with no height to give still seats one lane`() {
assertThat(week(eventsOnMonday = 6).capAt(rowFor(0))).isEqualTo(1)
assertThat(week(eventsOnMonday = 6).capAt(10.dp)).isEqualTo(1)
}
@Test
fun `an empty week never charges itself for dots`() {
assertThat(week(eventsOnMonday = 0).capAt(rowFor(80))).isEqualTo(4)
}
}