Merge remote-tracking branch 'origin/release/v2.20.0' into feat/220-today-button-date
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user