Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63dbe46a88 | ||
|
|
bdcf2b3823 |
@@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -197,18 +196,26 @@ fun rememberTimelinePinchZoom(
|
||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||
*
|
||||
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||
* that keeps its own promise — up for the fill floor, so no dead space opens
|
||||
* under midnight, down for the ceiling. A fractional bound would be a height the
|
||||
* pinch can be held against but never actually land on, and the difference feeds
|
||||
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
|
||||
* pinch can actually land on.
|
||||
*
|
||||
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
|
||||
* day exactly fills the viewport at, and it is the same value
|
||||
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
|
||||
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
|
||||
* hour taller than its own viewport, so a pinched-all-the-way-out day still
|
||||
* scrolls a hair and bounces off Android's overscroll stretch, while the
|
||||
* identical FitDay preset sits still. Being the clamp result rather than a bound
|
||||
* the gesture is merely held against, it is a height the pinch does land on: the
|
||||
* next frame reads it back unchanged and the focal anchor is handed nothing to
|
||||
* correct.
|
||||
*/
|
||||
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||
// the two to disagree, dead space is the worse of the two failures.
|
||||
target.roundToInt().toFloat()
|
||||
.coerceAtMost(floor(maxPx))
|
||||
.coerceAtLeast(ceil(fillPx))
|
||||
.coerceAtLeast(fillPx)
|
||||
|
||||
/**
|
||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||
|
||||
@@ -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<LayoutCoordinates?>,
|
||||
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<EventInstance>,
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -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<MutableList<IntRange>>()
|
||||
return raw.map { r ->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -75,17 +76,16 @@ class TimelineZoomTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch held against a fractional bound stays put`() {
|
||||
// A bound that is not a whole pixel is a height the pinch can be pushed
|
||||
// against but never land on, so every frame of a held gesture would look
|
||||
// like a scale change and hand the focal anchor a scroll correction.
|
||||
fun `a pinch held against either bound stays put`() {
|
||||
val fillPx = 62.083f
|
||||
val maxPx = 616.5f
|
||||
|
||||
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
||||
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
||||
|
||||
assertThat(floor).isEqualTo(63f)
|
||||
// The ceiling is pulled onto the pixel grid so it stays landable; the
|
||||
// fill floor is landable as it is, being the clamp result itself.
|
||||
assertThat(floor).isEqualTo(fillPx)
|
||||
assertThat(ceiling).isEqualTo(616f)
|
||||
// Landing there and being pushed further must not move them again.
|
||||
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
||||
@@ -100,6 +100,35 @@ class TimelineZoomTest {
|
||||
assertThat(floor * 24).isAtLeast(viewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinching all the way out leaves nothing to scroll`() {
|
||||
// #290: rounding the fill floor up to a whole pixel made the day one
|
||||
// pixel per hour taller than the viewport it was supposed to fill, so a
|
||||
// fully zoomed-out timeline still scrolled a hair and bounced off
|
||||
// Android's overscroll stretch -- while FitDay, at the same zoom, sat
|
||||
// still. 1490 is deliberately not divisible by 24.
|
||||
val viewport = 1490f
|
||||
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
|
||||
assertThat(floor * 24).isWithin(0.01f).of(viewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pinch floor is the height FitDay resolves to`() {
|
||||
// The inconsistency the issue is about: the two ways to reach "the whole
|
||||
// day on one screen" have to arrive at the same height.
|
||||
val density = Density(2.5f)
|
||||
val viewport = 596.dp
|
||||
with(density) {
|
||||
val fitDay = TimelineScale.FitDay.hourHeight(viewport).toPx()
|
||||
val pinched = pinchedHourHeightPx(
|
||||
target = 1f,
|
||||
fillPx = fillHourHeight(viewport).toPx(),
|
||||
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
||||
)
|
||||
assertThat(pinched).isWithin(0.01f).of(fitDay)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settled pinch is what gets persisted`() {
|
||||
var persisted: TimelineScale? = null
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user