Compare commits

...
12 changed files with 525 additions and 138 deletions
@@ -32,6 +32,68 @@ val BLOCK_TEXT_PADDING = 4.dp
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp
/**
* What a timed block of a given height has to spend on text, and what each line
* of it costs.
*/
@Immutable
data class BlockTextMetrics(
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
val inset: Dp,
/** Height left for text once [inset] is paid at both edges. */
val available: Dp,
/** What the first line of a title draws in. */
val titleLine: Dp,
/** What every title line after the first adds. */
val titleLeading: Dp,
/** What the time label's one line draws in. */
val timeLine: Dp,
) {
/** Whether the block can draw a title at all. */
val fitsTitle: Boolean get() = available >= titleLine
/** Height a title of [lines] lines occupies. */
fun titleHeight(lines: Int): Dp =
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
/** Title lines that fit [within], which may be none. */
fun titleBudget(within: Dp): Int =
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
}
/**
* The vertical padding a block [height] tall can afford around a title line of
* [titleLine].
*
* The inset is what the block gives up first: breathing room is worth having
* where there is room to breathe, but on a block down to its last few pixels a
* bare colour chip where a label would have fit reads as a rendering fault. It
* tapers rather than snapping, so a pinch closes the gap gradually instead of
* dropping it in one frame (#289).
*/
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
/** Text metrics for a timed block [height] tall. */
@Composable
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
val titleStyle = MaterialTheme.typography.labelMedium
val titleLine = rememberTrimmedLineHeight(titleStyle)
// Packed, so a second line costs what the first did rather than a whole
// Material line box — the gap between two lines of a wrapped title is the
// one place a block pays that leading twice (#190).
val titleLeading = titleLine
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
val inset = blockTextInset(height, titleLine)
return BlockTextMetrics(
inset = inset,
available = height - inset * 2,
titleLine = titleLine,
titleLeading = titleLeading,
timeLine = timeLine,
)
}
/** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2
@@ -70,9 +132,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val timeLineHeight = rememberTrimmedLineHeight(
MaterialTheme.typography.labelSmall.asEventTime(),
)
return if (spare >= timeLineHeight) {
blockTextLines(
text = label,
@@ -108,8 +170,10 @@ fun BlockTitle(
Text(
text = title,
modifier = modifier,
style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
style = rememberPackedLines(
MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
),
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
@@ -141,6 +205,8 @@ fun BlockTimeLabel(
MaterialTheme.motionScheme.fastEffectsSpec()
}
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
// Regular weight against the title's medium above it (#219).
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
Crossfade(
targetState = label,
animationSpec = spec,
@@ -149,8 +215,7 @@ fun BlockTimeLabel(
) { text ->
Text(
text = text,
// Regular weight against the title's medium above it (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
style = style,
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
@@ -83,8 +83,10 @@ fun calendarSlideTransition(
initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no
// size change here to contain — both pages are the same grid.
// shears the pages against the viewport edge as they pass. Left off even
// where the two pages differ in height — the split grid stands as many
// 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),
)
}
@@ -790,20 +790,15 @@ private fun DragCopy(
val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// The block's size, spent the block's way — text sits at the top as it does
// on the block, so the copy hands back to the grid without shifting (#267).
// on the block, so the copy hands back to the grid without shifting (#267),
// and it squeezes its inset on the same terms so a short block's title does
// not vanish the moment it is lifted (#289).
// The title is served in full first and the range lives off what is left:
// the hour gutter down the side still says where the copy sits, so the
// range is the half that can afford to go.
val available = height - BLOCK_TEXT_INSET * 2
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
val allowed = titleLines.coerceAtMost(titleBudget)
val metrics = rememberBlockTextMetrics(height)
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
// Re-measured at the copy's own width rather than spent on the source's
// count: a block sharing its column with another is a lane wide where the
// copy is a whole column, so the source's second line is one the copy never
@@ -818,10 +813,10 @@ private fun DragCopy(
max = allowed,
)
}
val left = available - titleLineHeight * lines
val showTime = label != null && left >= timeLineHeight
val left = metrics.available - metrics.titleHeight(lines)
val showTime = label != null && left >= metrics.timeLine
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - timeLineHeight)
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
} else {
1
}
@@ -857,7 +852,7 @@ private fun DragCopy(
clip = false
}
.eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
) {
Column {
if (lines > 0) {
@@ -83,9 +83,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
@@ -765,30 +765,24 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val metrics = rememberBlockTextMetrics(height)
// A block that cannot afford both lines spends its space on the title, and
// one too short even for that drops the title rather than serving a sliced one.
// Height alone decides: a duration threshold would keep hiding the time on a
// half-hour block the user has pinched open to three times the room it needs.
val available = height - BLOCK_TEXT_INSET * 2
val showTime = available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
val showTitle = metrics.fitsTitle
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// Only lines the block can actually draw: a block too short for the time is
// too short for a second title line too, and asking for one served a sliced
// one — as well as handing the drag copy a count it couldn't honour, so the
// title re-wrapped the moment the block was lifted (#267).
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// On a day column — wide enough for "09:30–11:00" several times over — the
// range never needs the second line, until lanes cut the column down.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() }
@@ -829,7 +823,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -10,10 +10,16 @@ import androidx.compose.ui.unit.dp
internal val CELL_GAP = 2.dp
/** Padding between a month chip's edge and its text. */
internal val MONTH_CHIP_TEXT_PADDING = 4.dp
internal val MONTH_CHIP_TEXT_PADDING = 3.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
/**
* A chip's own inset inside its day cell. The cell's gap and no more: a day
* column on a phone is around fifty dp, and the chip was spending a quarter of
* it on chrome before a single glyph. The cells keep their full separation from
* each other — what the grid reads as breathing room — and only the chip inside
* one takes the width back (#212).
*/
internal val MONTH_CHIP_INSET = CELL_GAP
/** 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
@@ -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
@@ -745,7 +746,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
) {
// Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
@@ -763,9 +764,34 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
* seats a one- or two-digit week number in a full-height tonal pill. */
private val WEEK_NUMBER_GUTTER = 40.dp
/** Padding between the week-number pill's edge and the number inside it. */
private val WEEK_NUMBER_PADDING = 6.dp
/** The widest week number an ISO year reaches; digits are tabular, so one
* measurement of it prices every week in the grid. */
private const val WEEK_NUMBER_SAMPLE = "53"
/**
* Width of the optional left calendar-week gutter (#25), measured rather than
* fixed: it is sized to the number it seats at the style the pill draws it in,
* so it follows the font scale instead of reserving slack for it, and spends
* nothing more on a column the grid would rather hand to the seven days (#213).
*/
@Composable
private fun rememberWeekNumberGutter(): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = weekNumberStyle()
return remember(style, density, measurer) {
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
}
}
/** The week number's own style — a step down from the day numbers beside it. */
@Composable
private fun weekNumberStyle() =
MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)
private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp
/** Named separately because the split style's selection outline draws its own
@@ -775,14 +801,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
@@ -794,6 +885,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
@@ -806,34 +902,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),
)
}
}
}
}
@@ -931,6 +1036,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.
@@ -1025,6 +1131,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 },
@@ -1142,10 +1249,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
* 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 grid slides between months like the paged style, which it can only do
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
* stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on
* top of swapping the grid — the pane now holds still and only the grid moves.
* The grid slides between months like the paged style, and stands only as many
* rows tall as its own month spans (#162). A swipe between a five-row month and
* a six-row one therefore moves the pane by a row as well as swapping the grid;
* the row it hands back is worth more to the pane than a still edge is.
*
* 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
@@ -1438,17 +1545,10 @@ 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).
/**
* 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
* comfortable tap target on its own.
@@ -1461,6 +1561,11 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
* The split style's grid (#53): the month compressed to day numbers and event
* 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
* 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.
@@ -1487,7 +1592,7 @@ internal fun SplitMonthGrid(
WeekNumberGutter(
weekStart = week.days.first(),
modifier = Modifier
.width(WEEK_NUMBER_GUTTER)
.width(rememberWeekNumberGutter())
.fillMaxHeight(),
)
}
@@ -1495,11 +1600,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
@@ -1519,17 +1624,11 @@ 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
* cell versus the filled circle the other views already use for today — so the
@@ -1903,7 +2002,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
@@ -1914,6 +2013,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,
@@ -1930,8 +2031,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
@@ -1984,9 +2087,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) },
),
)
@@ -2008,7 +2111,7 @@ private fun MonthWeekRow(
WeekNumberGutter(
weekStart = week.days.first(),
modifier = Modifier
.width(WEEK_NUMBER_GUTTER)
.width(rememberWeekNumberGutter())
.fillMaxHeight(),
)
}
@@ -2027,6 +2130,7 @@ private fun MonthWeekRow(
controller = dragController,
band = bandCoordinates,
rowHeightPx = rowHeightPx,
laneCap = laneCap,
isRtl = isRtl,
chipTimes = chipTimes,
),
@@ -2035,6 +2139,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.
@@ -2177,7 +2286,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(
@@ -2211,8 +2320,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),
@@ -2287,6 +2408,7 @@ private fun MonthWeekRow(
bandCoordinates,
),
rowHeightPx = rowHeightPx,
laneCap = laneCap,
)
if (chip != null) onEventClick(chip) else onOpenDay(d)
},
@@ -2328,11 +2450,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)
}
/**
@@ -2347,6 +2470,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). */
@@ -2365,7 +2489,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
@@ -2423,8 +2547,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
style = weekNumberStyle(),
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
@@ -2566,6 +2689,7 @@ private fun OverflowDots(
events: List<EventInstance>,
total: Int,
dark: Boolean,
rowHeight: Dp,
modifier: Modifier = Modifier,
) {
val soften = LocalSoftenColors.current
@@ -2573,14 +2697,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),
)
@@ -91,11 +91,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -573,7 +573,7 @@ private fun WeekDayHeader(
}
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number. */
* filled box and bold number — at the month grid's size, so the two agree (#213). */
@Composable
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
val label = stringResource(R.string.week_number_label)
@@ -585,9 +585,9 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
)
}
}
@@ -911,13 +911,7 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val available = height - BLOCK_TEXT_INSET * 2
val metrics = rememberBlockTextMetrics(height)
// Only full-width (non-overlapping) blocks that are tall enough show the
// time. On narrow overlapping columns we drop it so the title can wrap to
// fill the whole block, mirroring Google Calendar — and a block that cannot
@@ -925,19 +919,19 @@ private fun EventBlock(
// its own: a duration threshold would keep hiding the time on a half-hour
// block the user has pinched open to three times the room it needs.
val showTime = block.laneCount == 1 &&
available >= titleLineHeight + timeLineHeight
metrics.available >= metrics.titleLine + metrics.timeLine
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// A short block drops the title rather than serving a horizontally sliced
// one: half a letter reads as a rendering fault, while a bare colour chip
// reads as what it is — an event too brief to label. Tap still opens it, and
// the semantics description carries the full title either way.
val showTitle = available >= titleLineHeight
val showTitle = metrics.fitsTitle
// The title is served first, out of everything the block has left once the
// time is down to one line — but only takes the lines it will actually use,
// and only wraps at all once a line is wide enough to hold more than a
// syllable. Below that the extra lines just stack fragments of the word.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
val paint = eventPaint(block.event, dark)
// Every line the height affords, however narrow the lane: two events side by
// side leave columns well under a word wide, and cutting the title to one
@@ -950,8 +944,8 @@ private fun EventBlock(
textWidth = textWidth,
max = titleBudget,
)
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
@@ -993,7 +987,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -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 ->
@@ -34,6 +34,12 @@ class BackupUiStateTest {
@Test
fun `no calendars at all reports the empty device`() {
// This is #304's device. The screenshots on the issue show Calendula's
// own calendar list with no local calendar and an empty "synced
// calendars" section — the app saw nothing at all, so both halves of
// Backup & restore had nothing to draw and the screen came up blank.
// The reporter's "it's synced, I can see it in settings" meant Android's
// settings, not this app's.
assertThat(failure(backupUiState(emptyList())))
.isEqualTo(FailureReason.NoCalendarsConfigured)
}
@@ -46,9 +52,11 @@ class BackupUiStateTest {
}
@Test
fun `the reported case - only a calendar that cannot take events`() {
// #304: a Google account with calendar sync switched off leaves nothing
// exportable and nothing importable, which used to render a blank screen.
fun `an account that has stopped syncing is no import target`() {
// A calendar the provider no longer keeps events for: nothing to export
// and nowhere to import to, which used to render a blank screen. Not
// #304's device — that one had no calendars whatsoever — but the same
// dead end, and reachable on its own.
assertThat(failure(backupUiState(listOf(cal(1L, syncs = false)))))
.isEqualTo(FailureReason.NoImportTarget)
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class BlockTextMetricsTest {
/** One trimmed labelMedium line at font scale 1: a 12sp glyph, leading off. */
private val titleLine = 14.dp
/** A block with room to spare, for the line arithmetic. */
private fun metrics(height: Dp = 100.dp) = BlockTextMetrics(
inset = blockTextInset(height, titleLine),
available = height - blockTextInset(height, titleLine) * 2,
titleLine = titleLine,
titleLeading = 16.dp,
timeLine = 13.dp,
)
@Test
fun `a block with room keeps the full inset`() {
assertThat(blockTextInset(height = 60.dp, titleLine = titleLine))
.isEqualTo(BLOCK_TEXT_INSET)
}
@Test
fun `the inset tapers instead of snapping as the block shrinks`() {
// Half the inset left over is half the inset kept, so a pinch closes the
// gap frame by frame rather than dropping it in one.
assertThat(blockTextInset(height = titleLine + 2.dp, titleLine = titleLine))
.isEqualTo(1.dp)
}
@Test
fun `a block exactly one title tall spends nothing on padding`() {
assertThat(blockTextInset(height = titleLine, titleLine = titleLine)).isEqualTo(0.dp)
}
@Test
fun `a block shorter than a line never insets negatively`() {
assertThat(blockTextInset(height = 4.dp, titleLine = titleLine)).isEqualTo(0.dp)
}
@Test
fun `the title survives a block that used to be too short for it`() {
// 18dp is under the old floor — a title line plus 2dp of inset at each
// edge — and over the new one, which is the line on its own (#289).
val m = metrics(height = 18.dp)
assertThat(m.fitsTitle).isTrue()
}
@Test
fun `a block under one line still drops the title`() {
assertThat(metrics(height = 10.dp).fitsTitle).isFalse()
}
@Test
fun `every title line after the first costs a whole line box`() {
// The trim reaches the outer edges only, so the leading between two
// lines is still there to pay for.
val m = metrics()
assertThat(m.titleHeight(1)).isEqualTo(14.dp)
assertThat(m.titleHeight(2)).isEqualTo(30.dp)
assertThat(m.titleHeight(3)).isEqualTo(46.dp)
assertThat(m.titleHeight(0)).isEqualTo(0.dp)
}
@Test
fun `the budget is what the block can actually draw, not what divides into it`() {
val m = metrics()
// Two lines cost 30dp: 29 buys one, 30 buys the second.
assertThat(m.titleBudget(29.dp)).isEqualTo(1)
assertThat(m.titleBudget(30.dp)).isEqualTo(2)
assertThat(m.titleBudget(13.dp)).isEqualTo(0)
}
@Test
fun `a budget line is always one the block can pay for`() {
val m = metrics()
(0..80).forEach { dp ->
val within = dp.dp
val budget = m.titleBudget(within)
assertThat(m.titleHeight(budget)).isAtMost(within)
}
}
}
@@ -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)
}
}