diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 4b5d54b..ba2aac3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -123,6 +123,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign 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. */ 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 * 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 — - * close to what a five-row month gets on a typical phone. + * fixes one — close to what a five-row month gets on a typical phone. How many + * 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 @@ -793,6 +859,11 @@ private val CONTINUOUS_ROW_HEIGHT = 112.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. */ 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. */ selected: LocalDate? = null, ) { - Column( - modifier = Modifier - .fillMaxSize() - // Match the weekday header's inset so day cells sit under their - // labels, and so the week-number gutter's centre lines up with the - // top bar's hamburger (4dp bar inset + 24dp half icon button). - .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), - 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, - onEventClick = onEventClick, - selected = selected, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) + BoxWithConstraints(Modifier.fillMaxSize()) { + // The rows divide whatever the viewport leaves once the Column's own + // padding and the gaps between them are paid, so how many chips a cell + // can seat is only knowable here. + val rows = state.weeks.size.coerceAtLeast(1) + val rowHeight = + (maxHeight - GRID_VERTICAL_PADDING * 2 - GRID_ROW_GAP * (rows - 1)) / rows + Column( + modifier = Modifier + .fillMaxSize() + // Match the weekday header's inset so day cells sit under their + // labels, and so the week-number gutter's centre lines up with the + // top bar's hamburger (4dp bar inset + 24dp half icon button). + .padding(horizontal = AppBarSpacing.Inset, vertical = GRID_VERTICAL_PADDING), + verticalArrangement = Arrangement.spacedBy(GRID_ROW_GAP), + ) { + 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, + 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, zone = zone, timeChipWidth = timeChipWidth, + rowHeight = CONTINUOUS_ROW_HEIGHT, 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. @@ -1024,6 +1105,7 @@ internal fun DenseMonthGrid( today = state.today, zone = state.zone, timeChipWidth = timeChipWidth, + rowHeight = CONTINUOUS_ROW_HEIGHT, // Every day in the stream belongs to a month equally — there // is no "other month" to recede here. inMonth = { true }, @@ -1437,7 +1519,7 @@ private fun SplitExpandHandle( */ private val SPLIT_ROW_HEIGHT = 46.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 // would have no bar to become (#53). @@ -1494,11 +1576,11 @@ internal fun SplitMonthGrid( val inMonth = day.month == month.month && day.year == month.year // Seated by lane rather than gathered by colour, so each dot // 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( date = day, events = seated, - hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS), + hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES), isToday = day == state.today, // A page marks only the days its own month owns. Paging // moves the selection before this month's replacement @@ -1528,7 +1610,7 @@ internal fun SplitMonthGrid( } /** - * 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 * cell versus the filled circle the other views already use for today — so the @@ -1902,7 +1984,7 @@ private fun rememberSkeletonPulse(): Float { * 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 * 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". */ @Composable @@ -1913,6 +1995,8 @@ private fun MonthWeekRow( zone: TimeZone, /** The narrowest chip that may carry a start time, measured once per grid (#219). */ timeChipWidth: Dp, + /** The height this row was given, which decides its lanes — see [laneCapFor]. */ + rowHeight: Dp, inMonth: (LocalDate) -> Boolean, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, @@ -1929,8 +2013,10 @@ private fun MonthWeekRow( selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() + val overflowRow = rememberOverflowRowHeight() + val laneCap = week.laneCapFor(rowHeight, overflowRow) val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 - val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) + val shownLanes = laneCount.coerceAtMost(laneCap) 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 @@ -1983,9 +2069,9 @@ private fun MonthWeekRow( band = bandCoordinates[0], columnWidthPx = cell.size.width / 7f, laneHeightPx = rowHeightPx, - laneCount = MAX_EVENT_ROWS, + laneCount = laneCap, 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) }, ), ) @@ -2026,6 +2112,7 @@ private fun MonthWeekRow( controller = dragController, band = bandCoordinates, rowHeightPx = rowHeightPx, + laneCap = laneCap, isRtl = isRtl, chipTimes = chipTimes, ), @@ -2034,6 +2121,11 @@ private fun MonthWeekRow( // What a chip has to spend, against the [timeChipWidth] a start time // costs it (#219). 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 // week/day views use, so the three views share one visual language. @@ -2176,7 +2268,7 @@ private fun MonthWeekRow( .filter { it.lane < shownLanes && col in it.startCol..it.endCol } .map { it.lane } .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) pillsShown.forEachIndexed { i, ev -> MonthBar( @@ -2210,8 +2302,20 @@ private fun MonthWeekRow( events = hiddenEvents, total = hidden, dark = dark, + rowHeight = overflowRow, 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)) .width(colW) .padding(horizontal = 3.dp), @@ -2286,6 +2390,7 @@ private fun MonthWeekRow( bandCoordinates, ), rowHeightPx = rowHeightPx, + laneCap = laneCap, ) if (chip != null) onEventClick(chip) else onOpenDay(d) }, @@ -2327,11 +2432,12 @@ internal fun MonthWeek.chipAtCellY( cellY: Float, bandTopInCell: Float?, rowHeightPx: Float, + laneCap: Int, ): EventInstance? { if (bandTopInCell == null || rowHeightPx <= 0f) return null val bandY = cellY - bandTopInCell if (bandY < 0f) return null - return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS) + return chipAt(col, (bandY / rowHeightPx).toInt(), laneCap) } /** @@ -2346,6 +2452,7 @@ private fun monthChipDragModifier( controller: MonthDragController?, band: Array, rowHeightPx: Float, + laneCap: Int, 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). */ @@ -2364,7 +2471,7 @@ private fun monthChipDragModifier( val event = if (bandY < 0f || columnPx <= 0f) { null } else { - week.chipAt(dayIndex, lane, MAX_EVENT_ROWS) + week.chipAt(dayIndex, lane, laneCap) } if (event == null || moveScope?.allows(event) != true) { false @@ -2565,6 +2672,7 @@ private fun OverflowDots( events: List, total: Int, dark: Boolean, + rowHeight: Dp, modifier: Modifier = Modifier, ) { val soften = LocalSoftenColors.current @@ -2572,14 +2680,14 @@ private fun OverflowDots( val byColor = events.groupBy { it.color } val dots = byColor.keys.take(3) Row( - modifier = modifier.height(EVENT_ROW_HEIGHT), + modifier = modifier.height(rowHeight), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { dots.forEach { argb -> Box( modifier = Modifier - .size(6.dp) + .size(OVERFLOW_DOT_SIZE) .alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f) .background(eventAccent(argb, dark, soften), CircleShape), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index edaed06..53c34eb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -167,7 +167,7 @@ internal fun layoutAllDay( // 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 // 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. val laneCols = ArrayList>() return raw.map { r -> diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt index 2f1ee4c..727ad29 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt @@ -56,7 +56,13 @@ class ChipAtCellYTest { ) 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 fun `a tap on a lane resolves to the chip seated there`() { @@ -98,13 +104,13 @@ class ChipAtCellYTest { @Test 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()) } val week = rowOfJuly6(events) // 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() } @@ -113,10 +119,22 @@ class ChipAtCellYTest { val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L))) 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() 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() } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLaneCapTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLaneCapTest.kt new file mode 100644 index 0000000..0d7a207 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLaneCapTest.kt @@ -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) + } +}