From e720fd68ba71957fcfcf493a9f83f16ead26c516 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 7 Sep 2026 18:19:51 +0200 Subject: [PATCH] Seat each hour in its own cell (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeline's hour lines become a per-hour grid, in the month grid's visual language: 24 rounded cells on surfaceContainer, separated by a 2dp gap that reveals the surface behind them, so the hour boundary is negative space rather than a stroke. The column's Card takes surface and the container colour moves onto the cells. - `HourLines.kt` becomes `HourGrid.kt`: `hourGridCells` draws the cells in the same `drawBehind` the lines used, under the blocks and above the column. - The cell corner is the event chip's 4dp, not the month grid's 12dp, and is clamped to half the shorter side so a fit-the-day sliver or a narrow week column can't round into a lozenge. - The hour gutter's label lift comes off its own line height instead of a hardcoded -6dp, so label and seam line up at any font scale. - The preference keeps its stored key and its string keys; only the English source text and the Kotlin names change (`showHourLines` → `showHourGrid`). Block placement is not left untouched, as the issue expected: a block gives up the same half-gap at each end the cells do, or an on-the-hour event overhangs the seam into its neighbours' cells. That inset follows through to the drag — the floating copy takes the same gap off, so it stays the size of the block it lifted off (#267), and the grab is measured off the placement rather than the seated position. An end cut at midnight keeps none of it and stays against the column edge. Closes #113 --- CHANGELOG.md | 8 ++ .../jeanlucmakiola/calendula/MainActivity.kt | 4 +- .../calendula/data/prefs/SettingsPrefs.kt | 9 +- .../calendula/ui/common/HourGrid.kt | 83 +++++++++++++++++++ .../calendula/ui/common/HourGutter.kt | 10 ++- .../calendula/ui/common/HourLines.kt | 38 --------- .../calendula/ui/common/PastEvents.kt | 2 +- .../calendula/ui/common/TimelineDrag.kt | 41 +++++++-- .../calendula/ui/day/DayScreen.kt | 63 ++++++++++---- .../calendula/ui/settings/SettingsUiState.kt | 4 +- .../ui/settings/SettingsViewModel.kt | 14 ++-- .../calendula/ui/settings/ViewsSettings.kt | 6 +- .../calendula/ui/week/WeekScreen.kt | 64 ++++++++++---- app/src/main/res/values/strings.xml | 4 +- .../calendula/data/prefs/SettingsPrefsTest.kt | 6 +- .../ui/common/DragPieceBoundsTest.kt | 80 ++++++++++++++++++ .../calendula/ui/common/HourGridTest.kt | 53 ++++++++++++ 17 files changed, 390 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/HourGridTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe53c1..0a59689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is not in that row. Event times across the month, week and day views are also set in a regular weight against the title's, so a time reads as a time rather than as part of the name next to it ([#219]). +- **The hour lines in week and day view are now an hour grid.** The setting + that drew a faint separator line at each hour now seats every hour in its own + rounded cell, so an hour boundary reads as the seam between two surfaces + rather than a line drawn across one — the same negative space that separates + the month grid's days and the week's columns. An event that starts on the hour + fills its cell instead of overhanging the seam, and one that runs past + midnight still meets the edge of its column ([#113]). - The calendar titles now shorten instead of being cut off. When the full month name doesn't fit the top bar, the month and week views fall back to its three-letter form and the day view drops the weekday, rather than trailing off @@ -1603,3 +1610,4 @@ automatically, with zero telemetry and no internet permission. [#248]: https://codeberg.org/jlmakiola/calendula/issues/248 [#253]: https://codeberg.org/jlmakiola/calendula/issues/253 [#273]: https://codeberg.org/jlmakiola/calendula/issues/273 +[#113]: https://codeberg.org/jlmakiola/calendula/issues/113 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index e8bdb5c..b2a9d07 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -31,7 +31,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.ui.RootScreen -import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -171,7 +171,7 @@ class MainActivity : AppCompatActivity() { Box(modifier = Modifier.fillMaxSize()) { CompositionLocalProvider( LocalUse24HourFormat provides use24Hour, - LocalShowHourLines provides settings.showHourLines, + LocalShowHourGrid provides settings.showHourGrid, LocalTimelineZoom provides timelineZoom, LocalSoftenColors provides settings.softenColors, ) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 6bc5849..9d8d7fa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -212,14 +212,15 @@ class SettingsPrefs @Inject constructor( } /** - * Whether the week/day timeline draws a faint separator line at each hour - * (v2.11). Defaults to OFF — the historical clean look; users opt in. + * Whether the week/day timeline seats each hour in its own cell (v2.11, a + * separator line until v2.20). Defaults to OFF — the historical flat column; + * users opt in. The stored key keeps its original name. */ - val showHourLines: Flow = store.data.map { prefs -> + val showHourGrid: Flow = store.data.map { prefs -> prefs[SHOW_HOUR_LINES_KEY] ?: false } - suspend fun setShowHourLines(enabled: Boolean) { + suspend fun setShowHourGrid(enabled: Boolean) { store.edit { it[SHOW_HOUR_LINES_KEY] = enabled } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt new file mode 100644 index 0000000..330fb43 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Whether the week/day timeline seats each hour in its own cell, from the + * `show_hour_lines` preference. Provided once at the app root (like + * [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing. + * Defaults to off — the historical flat column. + */ +val LocalShowHourGrid = staticCompositionLocalOf { false } + +/** Gap between two hour cells, matching the month grid's gap between day cells. */ +val HOUR_CELL_GAP = 2.dp + +/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */ +val HOUR_CELL_INSET = HOUR_CELL_GAP / 2 + +/** + * The half-gap a block seated in the grid gives up at one of its ends, so an + * on-the-hour event fills its cell instead of overhanging the seam into its + * neighbours'. Zero with the grid off, and zero at an end [cut] at midnight: + * that edge is squared off against the column's own, and stopping a half-gap + * short of it would leave the block floating there rather than running off. + */ +fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp = + if (show && !cut) HOUR_CELL_INSET else 0.dp + +/** + * Corner radius of an hour cell: the event chip's own, so the grid never rounds + * harder than the blocks it seats. The month grid's 12dp belongs to a cell many + * times the size — on an hour cell it out-rounds its own content. + */ +private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER + +/** + * Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The + * hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a + * week column is a seventh of the screen, so the radius is held to half the + * shorter side — the point past which the corners would meet and the cell turn + * into a lozenge. + */ +internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float = + minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f) + +/** + * Seat each of the day's 24 hours in its own rounded cell when [show] is true, + * so the hour boundary reads as a seam between two surfaces rather than a line + * drawn across one. Applied to a day column's content, so the cells sit over the + * column background but beneath the event blocks — blocks stay in a continuous + * coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel + * height; [color] is resolved by the caller from the theme. + */ +fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier = + if (!show) { + this + } else { + drawBehind { + val inset = HOUR_CELL_INSET.toPx() + val cellHeight = hourHeightPx - inset * 2f + if (cellHeight <= 0f) return@drawBehind + val radius = CornerRadius( + hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), + ) + val cellSize = Size(size.width, cellHeight) + for (hour in 0 until 24) { + drawRoundRect( + color = color, + topLeft = Offset(0f, hour * hourHeightPx + inset), + size = cellSize, + cornerRadius = radius, + ) + } + } + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index cf4449e..67c944c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -72,6 +73,11 @@ fun HourGutter( targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f, label = "hourLabelAlpha", ) + // Each label straddles the boundary it names, so it lines up with the seam + // the hour grid leaves there instead of hanging below it. Derived from the + // label's own line height, so it holds at any font scale. + val labelStyle = MaterialTheme.typography.labelSmall + val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2 Box( modifier = modifier @@ -90,12 +96,12 @@ fun HourGutter( if (h > 0) { Text( text = formatHourLabel(h, use24Hour, locale), - style = MaterialTheme.typography.labelSmall, + style = labelStyle, color = MaterialTheme.colorScheme.onSurfaceVariant .copy(alpha = hourAlpha), modifier = Modifier .align(Alignment.TopCenter) - .offset(y = (-6).dp), + .offset(y = -labelLift), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt deleted file mode 100644 index 6ae2d71..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt +++ /dev/null @@ -1,38 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.common - -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color - -/** - * Whether the week/day timeline draws an hour separator line, from the - * `showHourLines` preference. Provided once at the app root (like - * [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing. - * Defaults to off — the historical clean look. - */ -val LocalShowHourLines = staticCompositionLocalOf { false } - -/** - * Draw a faint separator line at the top of each hour (1..23) when [show] is - * true. Applied to a day column's content so each line sits over the column's - * background but beneath the event blocks. [hourHeightPx] is one hour's pixel - * height; [color] is resolved by the caller from the theme. - */ -fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier = - if (!show) { - this - } else { - drawBehind { - for (hour in 1 until 24) { - val y = hour * hourHeightPx - drawLine( - color = color, - start = Offset(0f, y), - end = Offset(size.width, y), - strokeWidth = 1f, - ) - } - } - } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt index 0e73450..cf988c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt @@ -12,7 +12,7 @@ import kotlin.time.Instant * drawn dimmed in the month/week grids — i.e. the current wall-clock minute when * the "dim completed events" setting is on, or `null` when it is off (nothing * dims). Provided per grid screen so only the event chips that read it recompose - * as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat]. + * as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat]. */ val LocalDimCutoff = compositionLocalOf { null } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 8fa21c7..e5ac2f2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -200,6 +200,32 @@ internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int = 0 } +/** + * Where one dragged piece's top edge sits below its column's top, and how tall + * it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated + * in the hour grid gives up at each end, zero with the grid off — taken off the + * copy exactly as the grid takes it off the block, or the copy is a gap taller + * than the block it lifted off and spends the difference on a time label that + * block had no room for (#267). An end cut at midnight keeps none of it, as the + * block's own squared-off edge does. + */ +internal fun dragPieceBounds( + slice: DragSlice, + hourPx: Float, + insetPx: Float, +): DragPieceBounds { + val top = if (slice.continuesBefore) 0f else insetPx + val bottom = if (slice.continuesAfter) 0f else insetPx + val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx) + return DragPieceBounds( + top = slice.startMin / 60f * hourPx + top, + height = (full - top - bottom).coerceAtLeast(0f), + ) +} + +/** A piece's placement within its column — see [dragPieceBounds]. */ +internal data class DragPieceBounds(val top: Float, val height: Float) + /** One day's share of a dragged event, before it is placed on screen. */ internal data class DragSlice( val dayOffset: Int, @@ -243,6 +269,13 @@ class TimelineGeometry { var stepPx: Float = 0f var days: List = emptyList() + /** + * The half-gap a block gives up at each uncut end when the hour grid seats + * it in a cell, zero with the grid off. The floating copy gives up the same, + * so it is the size of the block it lifted off (#267). + */ + var blockInsetPx: Float = 0f + /** * Whether the columns are laid out right-to-left. Pointer coordinates are * never mirrored but the grid is, so the mapping has to flip with it. @@ -488,17 +521,15 @@ class TimelineDragController { .map { slice -> val day = dayIndex + slice.dayOffset val col = if (geometry.isRtl) days.lastIndex - day else day + val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx) TimelineDragPiece( topLeftInRoot = Offset( x = origin.x + col * columnPx, - y = origin.y + slice.startMin / 60f * hourPx, + y = origin.y + bounds.top, ), sizePx = IntSize( (columnPx - geometry.columnGapPx).roundToInt(), - maxOf( - slice.spanMin / 60f * hourPx, - MIN_EVENT_FRACTION * hourPx, - ).roundToInt(), + bounds.height.roundToInt(), ), continuesBefore = slice.continuesBefore, continuesAfter = slice.continuesAfter, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index bbd2669..541cea7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -99,6 +99,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes import de.jeanlucmakiola.calendula.ui.common.continuesAfter import de.jeanlucmakiola.calendula.ui.common.continuesBefore +import de.jeanlucmakiola.calendula.ui.common.ChipCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockShape import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed @@ -117,7 +118,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.hourHeight @@ -126,7 +127,8 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET -import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines +import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset +import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.week.TimedBlock import kotlinx.coroutines.flow.first @@ -551,6 +553,12 @@ private fun Timeline( val zoom = LocalTimelineZoom.current val density = LocalDensity.current val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + // What the hour grid takes off a block's ends, for the floating copy to + // take off its own. Per-end, a cut edge keeps it — that much the drag + // geometry decides itself, from the slice it is drawing. + val blockInsetPx = with(density) { + hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx() + } // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top @@ -604,6 +612,7 @@ private fun Timeline( it.grid = coords it.scroll = scrollState it.hourPx = with(density) { hourHeight.toPx() } + it.blockInsetPx = blockInsetPx it.columnGapPx = 0f it.columnWidthPx = coords.size.width.toFloat() it.days = listOf(state.date) @@ -630,8 +639,8 @@ private fun DayColumnCard( modifier: Modifier = Modifier, ) { val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - val showHourLines = LocalShowHourLines.current - val hourLineColor = MaterialTheme.colorScheme.outlineVariant + val showHourGrid = LocalShowHourGrid.current + val zone = remember { TimeZone.currentSystemDefault() } // Tells a settled drop when this column has caught up with it. LaunchedEffect(blocks, dragController.settling) { dragController.noteGrid(date, blocks) @@ -647,16 +656,23 @@ private fun DayColumnCard( // rounded scroll viewport, so inner rounding would look odd at the edges. shape = RectangleShape, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, + // With the grid on, the container colour moves onto the hour cells + // and the column behind them recedes to surface, so an hour boundary + // reads as negative space between two surfaces. + containerColor = if (showHourGrid) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceContainer + }, ), modifier = modifier, ) { BoxWithConstraints( modifier = Modifier .fillMaxSize() - // Faint hour separators sit over the column background but under - // the event blocks (drawBehind paints before the children). - .hourSeparatorLines(showHourLines, hourPx, hourLineColor) + // The hour cells sit over the column background but under the + // event blocks (drawBehind paints before the children). + .hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer) // Tap an empty slot to create an event there. Taps on event // blocks are consumed by their own click handler first, so this // only fires on the column background. Snaps to the tapped hour. @@ -688,19 +704,33 @@ private fun DayColumnCard( width = laneWidth, height = height, ) + // Where this block is cut at midnight, which decides both + // its shape and the half-gap it gives up to the hour cells. + val cuts = remember(block, date, zone) { + timedBlockCuts( + block.continuesBefore(date, zone), + block.continuesAfter(date, zone), + ) + } + val topInset = hourCellBlockInset(showHourGrid, cuts.top) + val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) + val blockHeight = (place.height - topInset - bottomInset) + .coerceAtLeast(0.dp) EventBlock( block = block, dark = dark, - height = place.height, + height = blockHeight, width = place.width, date = date, + cuts = cuts, + topInset = topInset, dragController = dragController, onClick = { onEventClick(block.event) }, onDrop = onDrop, modifier = Modifier - .offset(x = place.x, y = place.y) + .offset(x = place.x, y = place.y + topInset) .width(place.width) - .height(place.height) + .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), ) } @@ -720,6 +750,8 @@ private fun EventBlock( height: Dp, width: Dp, date: LocalDate, + cuts: ChipCuts, + topInset: Dp, dragController: TimelineDragController, onClick: () -> Unit, onDrop: (TimelineDrop) -> Unit, @@ -763,10 +795,8 @@ private fun EventBlock( // The drop takes this offset back off, so a tail clipped at midnight lands // where the event's own start belongs (#253). val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } - val cuts = remember(block, date, zone) { - timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) - } val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) } + val topInsetPx = with(density) { topInset.toPx() } val dragModifier = rememberEventDragSource( enabled = draggable, key = block.event.instanceId, @@ -776,7 +806,10 @@ private fun EventBlock( clipOffsetMin = clipOffset, titleLines = if (showTitle) titleMaxLines else 0, pointerInRoot = pointer, - blockInRoot = blockRoot, + // Measured off the block's placement rather than off where the + // hour grid seats it: the copy is drawn from that placement and + // re-seated the same half-gap down (#267). + blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx), ) }, onMove = dragController::move, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index effeb71..d3f6f9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -32,8 +32,8 @@ data class SettingsUiState( val weekStart: WeekStartPref = WeekStartPref.Auto, /** Clock convention for time labels (v2.11). AUTO follows the system setting. */ val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, - /** Whether the week/day timeline draws an hour separator line (v2.11). */ - val showHourLines: Boolean = false, + /** Whether the week/day timeline seats each hour in its own cell (v2.11). */ + val showHourGrid: Boolean = false, /** How the Agenda screen treats events that already ended today. */ val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW, /** Whether the month/week grids fade events that have already finished. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index edfbd4e..f6a0105 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -141,7 +141,7 @@ class SettingsViewModel @Inject constructor( // Display toggles folded into one flow so they fit this group — // the outer combine is already at its five-arg limit. combine( - prefs.showHourLines, + prefs.showHourGrid, prefs.showWeekNumbers, prefs.agendaShowToday, prefs.softenCalendarColors, @@ -158,7 +158,7 @@ class SettingsViewModel @Inject constructor( ) { view, screenRange, widgetRange, timeFormat, toggles -> ViewSettings( view, screenRange, widgetRange, timeFormat, - showHourLines = toggles.showHourLines, + showHourGrid = toggles.showHourGrid, showWeekNumbers = toggles.showWeekNumbers, agendaShowToday = toggles.agendaShowToday, softenColors = toggles.softenColors, @@ -191,7 +191,7 @@ class SettingsViewModel @Inject constructor( agendaScreenRange = views.agendaScreenRange, agendaWidgetRange = views.agendaWidgetRange, timeFormat = views.timeFormat, - showHourLines = views.showHourLines, + showHourGrid = views.showHourGrid, showWeekNumbers = views.showWeekNumbers, agendaShowToday = views.agendaShowToday, softenColors = views.softenColors, @@ -292,7 +292,7 @@ class SettingsViewModel @Inject constructor( val agendaScreenRange: AgendaRange, val agendaWidgetRange: AgendaRange, val timeFormat: TimeFormatPref, - val showHourLines: Boolean, + val showHourGrid: Boolean, val showWeekNumbers: Boolean, val agendaShowToday: Boolean, val softenColors: Boolean, @@ -301,7 +301,7 @@ class SettingsViewModel @Inject constructor( ) private data class DisplayToggles( - val showHourLines: Boolean, + val showHourGrid: Boolean, val showWeekNumbers: Boolean, val agendaShowToday: Boolean, val softenColors: Boolean, @@ -532,8 +532,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTimeFormat(pref) } } - fun setShowHourLines(enabled: Boolean) { - viewModelScope.launch { prefs.setShowHourLines(enabled) } + fun setShowHourGrid(enabled: Boolean) { + viewModelScope.launch { prefs.setShowHourGrid(enabled) } } fun setShowWeekNumbers(enabled: Boolean) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index bdf44f8..a0c988f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -170,11 +170,11 @@ internal fun ViewsScreen( position = Position.Bottom, trailing = { Switch( - checked = state.showHourLines, - onCheckedChange = viewModel::setShowHourLines, + checked = state.showHourGrid, + onCheckedChange = viewModel::setShowHourGrid, ) }, - onClick = { viewModel.setShowHourLines(!state.showHourLines) }, + onClick = { viewModel.setShowHourGrid(!state.showHourGrid) }, ) Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 0e5d1cd..8bc6dad 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -107,6 +107,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes import de.jeanlucmakiola.calendula.ui.common.continuesAfter import de.jeanlucmakiola.calendula.ui.common.continuesBefore +import de.jeanlucmakiola.calendula.ui.common.ChipCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockShape import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed @@ -127,7 +128,7 @@ import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.hourHeight @@ -137,7 +138,8 @@ import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET -import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines +import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset +import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next @@ -686,6 +688,12 @@ private fun Timeline( val zoom = LocalTimelineZoom.current val density = LocalDensity.current val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + // What the hour grid takes off a block's ends, for the floating copy to + // take off its own. Per-end, a cut edge keeps it — that much the drag + // geometry decides itself, from the slice it is drawing. + val blockInsetPx = with(density) { + hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx() + } // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top @@ -732,6 +740,7 @@ private fun Timeline( it.grid = coords it.scroll = scrollState it.hourPx = with(density) { hourHeight.toPx() } + it.blockInsetPx = blockInsetPx it.columnGapPx = gap it.columnWidthPx = (coords.size.width + gap) / state.days.size it.days = state.days @@ -776,8 +785,8 @@ private fun DayColumnCard( modifier: Modifier = Modifier, ) { val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - val showHourLines = LocalShowHourLines.current - val hourLineColor = MaterialTheme.colorScheme.outlineVariant + val showHourGrid = LocalShowHourGrid.current + val zone = remember { TimeZone.currentSystemDefault() } // Tells a settled drop when this column has caught up with it. LaunchedEffect(blocks, dragController.settling) { dragController.noteGrid(date, blocks) @@ -793,16 +802,24 @@ private fun DayColumnCard( // rounded scroll viewport, so inner rounding would look odd at the edges. shape = RectangleShape, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, + // With the grid on, the container colour moves onto the hour cells + // and the column behind them recedes to surface, so the gap between + // two cells reads as the same negative space that separates the + // week's columns. + containerColor = if (showHourGrid) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceContainer + }, ), modifier = modifier, ) { BoxWithConstraints( modifier = Modifier .fillMaxSize() - // Faint hour separators sit over the column background but under - // the event blocks (drawBehind paints before the children). - .hourSeparatorLines(showHourLines, hourPx, hourLineColor) + // The hour cells sit over the column background but under the + // event blocks (drawBehind paints before the children). + .hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer) // Tap an empty slot to create an event there; taps on event // blocks are consumed by their own handler first. Snaps to hour. .pointerInput(date) { @@ -833,19 +850,33 @@ private fun DayColumnCard( width = laneWidth, height = height, ) + // Where this block is cut at midnight, which decides both + // its shape and the half-gap it gives up to the hour cells. + val cuts = remember(block, date, zone) { + timedBlockCuts( + block.continuesBefore(date, zone), + block.continuesAfter(date, zone), + ) + } + val topInset = hourCellBlockInset(showHourGrid, cuts.top) + val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) + val blockHeight = (place.height - topInset - bottomInset) + .coerceAtLeast(0.dp) EventBlock( block = block, dark = dark, - height = place.height, + height = blockHeight, width = place.width, date = date, + cuts = cuts, + topInset = topInset, dragController = dragController, onClick = { onEventClick(block.event) }, onDrop = onDrop, modifier = Modifier - .offset(x = place.x, y = place.y) + .offset(x = place.x, y = place.y + topInset) .width(place.width) - .height(place.height) + .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), ) } @@ -865,6 +896,8 @@ private fun EventBlock( height: Dp, width: Dp, date: LocalDate, + cuts: ChipCuts, + topInset: Dp, dragController: TimelineDragController, onClick: () -> Unit, onDrop: (TimelineDrop) -> Unit, @@ -926,10 +959,8 @@ private fun EventBlock( // The drop takes this offset back off, so a tail clipped at midnight lands // where the event's own start belongs (#253). val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } - val cuts = remember(block, date, zone) { - timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) - } val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) } + val topInsetPx = with(density) { topInset.toPx() } val dragModifier = rememberEventDragSource( enabled = draggable, key = block.event.instanceId, @@ -939,7 +970,10 @@ private fun EventBlock( clipOffsetMin = clipOffset, titleLines = if (showTitle) titleMaxLines else 0, pointerInRoot = pointer, - blockInRoot = blockRoot, + // Measured off the block's placement rather than off where the + // hour grid seats it: the copy is drawn from that placement and + // re-seated the same half-gap down (#267). + blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx), ) }, onMove = dragController::move, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index add7a77..a0130cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -431,8 +431,8 @@ 24-hour (14:00) Following the system: %1$s - Hour lines - Show a separator line at each hour in week and day view + Hour grid + Seat each hour in its own cell in week and day view Hour height How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between. Fit whole day diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index abe5922..e4b28f0 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -95,9 +95,9 @@ class SettingsPrefsTest { @Test fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - assertThat(prefs.showHourLines.first()).isFalse() - prefs.setShowHourLines(true) - assertThat(prefs.showHourLines.first()).isTrue() + assertThat(prefs.showHourGrid.first()).isFalse() + prefs.setShowHourGrid(true) + assertThat(prefs.showHourGrid.first()).isTrue() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt new file mode 100644 index 0000000..5a5777c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt @@ -0,0 +1,80 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The floating copy is the size of the block it lifted off, hour grid or not — + * a copy a half-gap taller at each end has room for a time label the block + * itself had to drop, so the label appears on long-press and goes on drop + * (#267). + */ +class DragPieceBoundsTest { + + /** A Regular 56dp hour on a 3x screen. */ + private val hourPx = 168f + + /** The grid's half-gap, 1dp at 3x. */ + private val insetPx = 3f + + private fun slice( + startMin: Int, + spanMin: Int, + continuesBefore: Boolean = false, + continuesAfter: Boolean = false, + ) = DragSlice( + dayOffset = 0, + startMin = startMin, + spanMin = spanMin, + continuesBefore = continuesBefore, + continuesAfter = continuesAfter, + ) + + @Test + fun `with the grid off a piece keeps its raw placement`() { + val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx = 0f) + assertThat(bounds.top).isEqualTo(9 * hourPx) + assertThat(bounds.height).isEqualTo(hourPx) + } + + @Test + fun `with the grid on a piece gives up a half-gap at each end`() { + val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx) + assertThat(bounds.top).isEqualTo(9 * hourPx + insetPx) + assertThat(bounds.height).isEqualTo(hourPx - insetPx * 2) + } + + @Test + fun `an end cut at midnight keeps none of the inset`() { + val head = dragPieceBounds(slice(startMin = 1200, spanMin = 240, continuesAfter = true), hourPx, insetPx) + assertThat(head.top).isEqualTo(20 * hourPx + insetPx) + assertThat(head.height).isEqualTo(4 * hourPx - insetPx) + + val tail = dragPieceBounds(slice(startMin = 0, spanMin = 480, continuesBefore = true), hourPx, insetPx) + assertThat(tail.top).isEqualTo(0f) + assertThat(tail.height).isEqualTo(8 * hourPx - insetPx) + } + + @Test + fun `a whole day cut at both ends fills its column`() { + val bounds = dragPieceBounds( + slice(startMin = 0, spanMin = 1440, continuesBefore = true, continuesAfter = true), + hourPx, + insetPx, + ) + assertThat(bounds.top).isEqualTo(0f) + assertThat(bounds.height).isEqualTo(24 * hourPx) + } + + @Test + fun `a very short piece is floored before the inset comes off`() { + val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 5), hourPx, insetPx) + assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2) + } + + @Test + fun `an inset taller than the piece leaves no height at all`() { + val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx) + assertThat(bounds.height).isEqualTo(0f) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/HourGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/HourGridTest.kt new file mode 100644 index 0000000..cd0f6c1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/HourGridTest.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class HourGridTest { + + /** The hour cell's 4dp corner at a 3x density. */ + private val maxRadius = 12f + + @Test + fun `a tall cell rounds to the full radius`() { + // A Regular 56dp hour on a 3x screen, in a phone-width day column. + assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 900f, maxRadius = maxRadius)) + .isEqualTo(maxRadius) + } + + @Test + fun `a short cell rounds to half its height, never past a lozenge`() { + // Fit-the-day on a small viewport: the hour is shorter than the radius. + val radius = hourCellRadiusPx(cellHeight = 18f, cellWidth = 900f, maxRadius = maxRadius) + assertThat(radius).isEqualTo(9f) + } + + @Test + fun `a narrow cell rounds to half its width`() { + // Seven columns on a small phone, so the cell is narrower than it is tall. + assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 18f, maxRadius = maxRadius)) + .isEqualTo(9f) + } + + @Test + fun `a degenerate cell asks for no radius at all`() { + assertThat(hourCellRadiusPx(cellHeight = -4f, cellWidth = 900f, maxRadius = maxRadius)) + .isEqualTo(0f) + } + + @Test + fun `a seated block gives up the half-gap at an end of its own`() { + assertThat(hourCellBlockInset(show = true, cut = false)).isEqualTo(HOUR_CELL_INSET) + } + + @Test + fun `an end cut at midnight stays against the column edge`() { + assertThat(hourCellBlockInset(show = true, cut = true)).isEqualTo(0.dp) + } + + @Test + fun `with the grid off nothing is given up`() { + assertThat(hourCellBlockInset(show = false, cut = false)).isEqualTo(0.dp) + } +}