Seat each hour in its own cell (#113) (#280)

The hour lines in week and day view become a per-hour grid, in the same visual
language as the month grid: each hour is a rounded cell on `surfaceContainer`,
separated by a 2dp gap that reveals `surface` behind it, so the boundary is
negative space rather than a stroke drawn across the column. The column's Card
takes `surface` and the container colour moves onto the cells.

### What changed

- `HourLines.kt` → `HourGrid.kt`. `hourGridCells` draws 24 rounded rects in the
  same `drawBehind` the 23 lines used — still a background layer, so blocks keep
  their continuous coordinate space and keep spanning cells.
- The cell corner is the event chip's 4dp rather than the month grid's 12dp: on
  a cell this size the 12dp out-rounds its own content. It is clamped to half
  the shorter side, so neither a fit-the-day sliver nor a narrow week column
  rounds into a lozenge.
- The hour gutter's label lift is derived from the label's own line height
  instead of a hardcoded `-6.dp`, so the label straddles the seam at any font
  scale. This was the third defect the issue listed; the other two (a
  pixel-width stroke, and a "line" broken into seven segments by `COLUMN_GAP`)
  go away with the lines themselves.
- The preference keeps its stored key and its string keys — only the English
  source text and the Kotlin names change (`showHourLines` → `showHourGrid`),
  as the issue asked.

### Deviation

The issue expected block placement to be untouched. It isn't: a block gives up
the same half-gap at each end that the cells do, otherwise an on-the-hour event
overhangs the seam into its neighbours' cells and the grid reads as broken
wherever there is an event. That inset follows through to the drag — the
floating copy takes the same gap off so it stays exactly the size of the block
it lifted off (or it has room for a time label the block had to drop, and the
label appears on long-press and goes again on drop, #267), and the grab is
measured off the block's placement rather than off where the grid seats it. An
end cut at midnight keeps none of the inset and stays against the column edge,
where `timedBlockCuts` already squares it off.

### Note for translators

`settings_hour_lines` and its summary keep their keys but changed meaning, so
the 12 `values-*` files still describe a separator line until Weblate re-syncs.
Renaming the keys is not an option — stale keys in the translations are fatal.

Closes #113

Supersedes #279, which was opened against `main` by mistake and has been undone
there.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/280
This commit is contained in:
Jean-Luc Makiola
2026-09-07 20:02:33 +02:00
parent 60f05a5c65
commit b0d86af959
17 changed files with 394 additions and 99 deletions

View File

@@ -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

View File

@@ -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,
) {

View File

@@ -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<Boolean> = store.data.map { prefs ->
val showHourGrid: Flow<Boolean> = 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 }
}

View File

@@ -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,
)
}
}
}

View File

@@ -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),
)
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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<Instant?> { null }

View File

@@ -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<LocalDate> = 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,

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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) {

View File

@@ -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))

View File

@@ -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,

View File

@@ -431,8 +431,12 @@
<string name="settings_time_format_24h">24-hour (14:00)</string>
<!-- %1$s is a sample time written the way the system currently writes it. -->
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
<string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<!-- Reworded in v2.20 (#113): this setting no longer draws a separator line
at each hour, it seats each hour in its own cell. The key is kept, so any
translation of the old wording ("Hour lines" / "show a separator line at
each hour") describes a feature that no longer exists and needs redoing. -->
<string name="settings_hour_lines">Hour grid</string>
<string name="settings_hour_lines_summary">Seat each hour in its own cell in week and day view</string>
<string name="settings_timeline_scale">Hour height</string>
<string name="settings_timeline_scale_hint">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.</string>
<string name="timeline_scale_fit_day">Fit whole day</string>

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)
}
}