Compare commits

...
19 changed files with 991 additions and 49 deletions
@@ -170,6 +170,7 @@ class MainActivity : AppCompatActivity() {
// weeks and switching between the week and day view.
val timelineZoom = rememberTimelineZoom(
stored = settings.timelineScale,
hours = settings.timelineHours,
onPersist = settingsViewModel::setTimelineScale,
)
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
import de.jeanlucmakiola.calendula.ui.common.storageValue
@@ -287,6 +288,27 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
}
/**
* The hours the day, week and multi-day timelines fit to the screen when they
* open (#322). Off by default; the hours survive switching it off.
*/
val timelineHours: Flow<TimelineHours> = store.data.map { prefs ->
TimelineHours.of(
enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false,
startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR,
endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR,
)
}
suspend fun setTimelineHours(hours: TimelineHours) {
val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour)
store.edit {
it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled
it[TIMELINE_HOURS_START_KEY] = valid.startHour
it[TIMELINE_HOURS_END_KEY] = valid.endHour
}
}
/** How many days the multi-day view shows side by side (#193). */
val multiDayCount: Flow<Int> = store.data.map { prefs ->
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
@@ -1037,6 +1059,9 @@ class SettingsPrefs @Inject constructor(
private const val MINUTES_PER_DAY = 1_440
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled")
internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start")
internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end")
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
const val DEFAULT_MULTI_DAY_COUNT = 3
@@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor(
is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
todayLabel = context.getString(R.string.reminder_day_today),
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
@@ -27,12 +27,12 @@ import java.util.Locale
* alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
* already unambiguous)
* - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
* plus [todayLabel] — no time range to lean on)
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
*
* All-day instances already carry an explicit date, so they never gain a
* relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
* All-day instances store UTC midnights with an exclusive end, so they are read
* in UTC and the end day is the last *covered* day (issue #308).
*/
fun reminderTimeText(
beginMillis: Long,
@@ -43,6 +43,7 @@ fun reminderTimeText(
is24Hour: Boolean,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
todayLabel: String,
tomorrowLabel: String,
yesterdayLabel: String,
): String {
@@ -53,11 +54,16 @@ fun reminderTimeText(
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
return if (lastDay == firstDay) {
dateFormat.format(firstDay)
} else {
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
}
val start = relativeDayPrefix(
day = firstDay,
today = today,
firstDayOfWeek = firstDayOfWeek,
locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
) ?: todayLabel
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
}
val timeFormat = timeOfDayFormatter(is24Hour, locale)
@@ -0,0 +1,328 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Thickness of the out-of-view edge bar. */
private val EDGE_BAR_THICKNESS = 3.dp
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
private val EDGE_TOUCH_HEIGHT = 24.dp
/** How far from the viewport's edge a tap on empty column still hits the bar. */
private val EDGE_TAP_REACH = 16.dp
/**
* How far the bar sits in from the viewport's edges — enough to clear the
* timeline's rounded 16dp corners on the outer columns.
*/
private val EDGE_BAR_VERTICAL_INSET = 4.dp
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
/** Distance outside the viewport over which a bar fades before its event appears. */
private val EDGE_FADE_LEAD = 24.dp
/** The nearest timed blocks wholly above and below a column's viewport. */
@Immutable
data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
companion object {
val None = OutOfViewEvents(null, null)
}
}
/** Which edge of the viewport a hidden event sits past. */
enum class ViewportEdge { Top, Bottom }
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
internal fun renderedEndMin(block: TimedBlock): Float =
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
/**
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
* (minutes of the day), nearest first. A block leaves this set as soon as any
* part of its rendered height enters the viewport.
*/
fun outOfViewEvents(
blocks: List<TimedBlock>,
topMin: Float,
bottomMin: Float,
): OutOfViewEvents {
val above = blocks
.filter { renderedEndMin(it) <= topMin }
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
val below = blocks
.filter { it.startMin >= bottomMin }
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
return OutOfViewEvents(above, below)
}
/**
* The scroll offset that brings [block] into a [viewportPx]-tall viewport from
* past [edge], with [marginPx] of room so it clears the edge bar. An event above
* lands with its start at the top; one below lands whole at the bottom when it
* fits, and with its start at the top when it does not.
*/
fun revealScrollTarget(
block: TimedBlock,
edge: ViewportEdge,
hourPx: Float,
viewportPx: Int,
marginPx: Float,
maxScroll: Int,
): Int {
val startPx = block.startMin / 60f * hourPx
val endPx = renderedEndMin(block) / 60f * hourPx
val target = when (edge) {
ViewportEdge.Top -> startPx - marginPx
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
}
return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0))
}
/**
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
* and the tap is within [reachPx] of the viewport's edge.
*/
fun tappedEdge(
events: OutOfViewEvents,
y: Float,
viewportTop: Float,
viewportPx: Int,
reachPx: Float,
): ViewportEdge? = when {
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
else -> null
}
/** Fade while the event is still outside; never draw its bar at the boundary. */
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
gapPx <= 0f -> 0f
fadeLeadPx <= 0f -> 1f
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
}
/**
* The out-of-view state of one day column (#188): which events hide past its
* edges, and the scroll that reveals them.
*
* The bars take no touches themselves, so event blocks under them keep their
* taps and drags; the column's own tap handler offers taps to [tap] first.
*/
@Stable
class OutOfViewEdges internal constructor(
private val scrollState: ScrollState,
private val scope: CoroutineScope,
) {
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
internal var hourPx by mutableFloatStateOf(0f)
internal var fadeLeadPx by mutableFloatStateOf(0f)
internal var marginPx by mutableFloatStateOf(0f)
internal var reachPx by mutableFloatStateOf(0f)
internal var reduceMotion = false
internal var revealSpec: FiniteAnimationSpec<Float>? = null
val events: OutOfViewEvents by derivedStateOf {
val viewport = scrollState.viewportSize
if (viewport <= 0 || hourPx <= 0f) {
OutOfViewEvents.None
} else {
val top = scrollState.value.toFloat()
outOfViewEvents(
blocks = blocks,
topMin = top / hourPx * 60f,
bottomMin = (top + viewport) / hourPx * 60f,
)
}
}
/** Scrolls the event hidden past [edge] into view, if there is one. */
fun reveal(edge: ViewportEdge) {
val block = if (edge == ViewportEdge.Top) events.above else events.below
block ?: return
val target = revealScrollTarget(
block = block,
edge = edge,
hourPx = hourPx,
viewportPx = scrollState.viewportSize,
marginPx = marginPx,
maxScroll = scrollState.maxValue,
)
val spec = revealSpec
scope.launch {
if (reduceMotion || spec == null) {
scrollState.scrollTo(target)
} else {
scrollState.animateScrollTo(target, spec)
}
}
}
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean {
val edge = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
) ?: return false
reveal(edge)
return true
}
}
@Composable
fun rememberOutOfViewEdges(
blocks: List<TimedBlock>,
scrollState: ScrollState,
hourPx: Float,
): OutOfViewEdges {
val scope = rememberCoroutineScope()
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
val density = LocalDensity.current
val reduceMotion = rememberReduceMotion()
val revealSpec = rememberCalendarPageSpec()
SideEffect {
edges.blocks = blocks
edges.hourPx = hourPx
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
edges.reduceMotion = reduceMotion
edges.revealSpec = revealSpec
}
return edges
}
/**
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
* the colour of the nearest timed event hidden past that edge (#188).
*
* Lives inside the column's scrolling content and follows the viewport by
* offsetting against [scrollState] at placement rather than recomposing every
* frame. Opacity follows the gap to the hidden event and reaches zero before
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
* readers get a button.
*/
@Composable
fun BoxScope.OutOfViewEdgeBars(
edges: OutOfViewEdges,
scrollState: ScrollState,
dark: Boolean,
) {
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
val events = edges.events
EdgeBar(
block = events.above,
edge = ViewportEdge.Top,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value) },
)
EdgeBar(
block = events.below,
edge = ViewportEdge.Bottom,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
)
}
@Composable
private fun EdgeBar(
block: TimedBlock?,
edge: ViewportEdge,
dark: Boolean,
onReveal: (ViewportEdge) -> Unit,
scrollState: ScrollState,
hourPx: Float,
fadeLeadPx: Float,
modifier: Modifier = Modifier,
) {
block ?: return
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
val label = stringResource(
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
)
Box(
modifier = modifier
.fillMaxWidth()
.height(EDGE_TOUCH_HEIGHT)
.graphicsLayer {
val gapPx = when (edge) {
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
(scrollState.value + scrollState.viewportSize)
}
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
}
.semantics {
role = Role.Button
contentDescription = label
onClick {
onReveal(edge)
true
}
},
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = EDGE_BAR_HORIZONTAL_INSET,
vertical = EDGE_BAR_VERTICAL_INSET,
)
.height(EDGE_BAR_THICKNESS)
.background(color, CircleShape),
)
}
}
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* The hours the week and day timelines fit to the screen when they open (#322).
*
* Nothing outside the range is hidden: [TimelineScale.FitHours] only sizes the
* hour so that [startHour]..[endHour] fills the viewport, and the timeline opens
* scrolled to [startHour]. The hours are kept when [enabled] is switched off, so
* switching it back on restores the chosen range.
*/
data class TimelineHours(
val enabled: Boolean = false,
val startHour: Int = DEFAULT_START_HOUR,
val endHour: Int = DEFAULT_END_HOUR,
) {
val span: Int get() = endHour - startHour
companion object {
const val DEFAULT_START_HOUR = 7
const val DEFAULT_END_HOUR = 22
/** Shortest range that can be chosen, in hours. */
const val MIN_SPAN = 2
/**
* A range with [startHour] and [endHour] held to the day and at least
* [MIN_SPAN] apart; the start gives way when the two collide.
*/
fun of(enabled: Boolean, startHour: Int, endHour: Int): TimelineHours {
val end = endHour.coerceIn(MIN_SPAN, 24)
val start = startHour.coerceIn(0, end - MIN_SPAN)
return TimelineHours(enabled, start, end)
}
/** Start hours that still leave [MIN_SPAN] before [endHour]. */
fun startOptions(endHour: Int): List<Int> = (0..endHour - MIN_SPAN).toList()
/** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */
fun endOptions(startHour: Int): List<Int> = (startHour + MIN_SPAN..24).toList()
}
}
/**
* The scroll offset that puts [startHour] at the top of a timeline laid out
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that
* layout is not yet at [hourPx] per hour — the scale changes a frame before the
* content grows to match it, and scrolling against the old layout would land on
* the wrong hour.
*/
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
val laidOutHourPx = (maxValue + viewportPx) / 24f
if (abs(laidOutHourPx - hourPx) > 1f) return null
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
}
@@ -38,6 +38,12 @@ sealed interface TimelineScale {
/** A height the user pinched to. Build it through [custom], which clamps. */
data class Custom(val hourHeight: Dp) : TimelineScale
/**
* [startHour]..[endHour] fills the viewport (#322). Applied when a timeline
* opens with [TimelineHours] on, never picked from the preset list.
*/
data class FitHours(val startHour: Int, val endHour: Int) : TimelineScale
companion object {
/** The named steps the settings picker offers, coarse to roomy. */
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
@@ -74,6 +80,9 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
is TimelineScale.Custom -> this.hourHeight
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight))
is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat())
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight))
}
/**
@@ -134,6 +143,7 @@ val TimelineScale.labelRes: Int
TimelineScale.Regular -> R.string.timeline_scale_regular
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
is TimelineScale.Custom -> R.string.timeline_scale_custom
is TimelineScale.FitHours -> R.string.settings_visible_hours
}
@get:StringRes
@@ -144,11 +154,15 @@ val TimelineScale.descriptionRes: Int
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
is TimelineScale.FitHours -> R.string.settings_visible_hours_summary
}
/** Marks a stored custom height; the rest of the value is its dp. */
private const val CUSTOM_PREFIX = "custom:"
/** Marks a stored hour range, as `start-end`. */
private const val HOURS_PREFIX = "hours:"
/**
* Stored names for the presets. Spelled out rather than taken from `toString()`
* so R8 can't rename them out from under an existing install — and they match
@@ -171,6 +185,7 @@ private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
*/
fun TimelineScale.storageValue(): String = when (this) {
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour"
TimelineScale.FitDay,
TimelineScale.Compact,
TimelineScale.Regular,
@@ -188,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when {
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
?.let { TimelineScale.custom(it.dp) }
?: TimelineScale.Regular
stored.startsWith(HOURS_PREFIX) -> stored.removePrefix(HOURS_PREFIX).split('-')
.mapNotNull { it.toIntOrNull() }
.takeIf { it.size == 2 }
?.let { (start, end) -> TimelineHours.of(enabled = true, start, end) }
?.let { TimelineScale.FitHours(it.startHour, it.endHour) }
?: TimelineScale.Regular
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
}
@@ -13,11 +13,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -49,13 +53,49 @@ class TimelineZoom(
var isPinching: Boolean by mutableStateOf(false)
private set
/** The visible-hours preference (#322); a timeline re-fits when it changes. */
var hours: TimelineHours by mutableStateOf(TimelineHours())
private set
private var stored: TimelineScale = initial
/**
* Take a value that came from the preference. Ignored mid-pinch: the stored
* value is a frame or two behind the fingers there, and letting it land would
* snap the timeline back while the user is still pinching.
* snap the timeline back while the user is still pinching. With visible
* hours on it is only remembered, so a late-arriving preference can't undo
* the fit a timeline just opened with.
*/
fun adopt(stored: TimelineScale) {
if (!isPinching) scale = stored
this.stored = stored
if (!isPinching && !hours.enabled) scale = stored
}
/** Take the visible-hours preference; switching it off hands back the stored scale. */
fun adoptHours(hours: TimelineHours) {
val wasEnabled = this.hours.enabled
this.hours = hours
if (wasEnabled && !hours.enabled && !isPinching) scale = stored
}
/**
* A timeline is opening. With visible hours on, the range replaces whatever
* scale is in force — a stored preset and a pinch alike — and its start hour
* is returned for the timeline to scroll to; otherwise null.
*/
fun open(): Int? {
val hours = hours
if (!hours.enabled) return null
if (!isPinching) scale = TimelineScale.FitHours(hours.startHour, hours.endHour)
return hours.startHour
}
/**
* Holds the height [open] fitted, so a viewport that changes afterwards (the
* all-day strip following a swipe) doesn't rescale the hours under the scroll.
*/
fun holdFit(hourHeight: Dp) {
if (!isPinching && scale is TimelineScale.FitHours) scale = TimelineScale.Custom(hourHeight)
}
fun beginPinch() {
@@ -66,9 +106,10 @@ class TimelineZoom(
scale = TimelineScale.custom(hourHeight)
}
/** Settles the pinch. With visible hours on it only lasts until the next [open]. */
fun endPinch() {
isPinching = false
persist(scale)
if (!hours.enabled) persist(scale)
}
}
@@ -82,16 +123,18 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
/**
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
* back through [onPersist].
* The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing
* settled pinches back through [onPersist].
*/
@Composable
fun rememberTimelineZoom(
stored: TimelineScale,
hours: TimelineHours,
onPersist: (TimelineScale) -> Unit,
): TimelineZoom {
val persist by rememberUpdatedState(onPersist)
val zoom = remember { TimelineZoom(stored) { persist(it) } }
LaunchedEffect(hours) { zoom.adoptHours(hours) }
// Picking a preset in Settings has to reach the timelines, and so does the
// stored value arriving after the first frame.
LaunchedEffect(stored) { zoom.adopt(stored) }
@@ -242,3 +285,31 @@ internal fun anchoredScroll(
* as a scroll stays one.
*/
private const val PINCH_SLOP = 0.08f
/**
* Where a timeline opens (#322): scrolled to the start of the visible hours when
* they are on, otherwise centred on noon. Re-runs when the visible-hours
* preference changes, so a range picked in Settings shows on the way back.
*/
@Composable
fun OpenTimelineScroll(scrollState: ScrollState, zoom: TimelineZoom) {
val density = LocalDensity.current
LaunchedEffect(zoom, zoom.hours) {
val startHour = zoom.open()
if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours,
// so centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
} else {
val (target, hourHeight) = snapshotFlow {
val viewport = scrollState.viewportSize
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
val hourPx = with(density) { hourHeight.toPx() }
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
}.filterNotNull().first()
scrollState.scrollTo(target)
zoom.holdFit(hourHeight)
}
}
}
@@ -106,6 +106,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars
import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
@@ -131,6 +133,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.OpenTimelineScroll
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
@@ -142,7 +145,6 @@ 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
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -303,15 +305,10 @@ private fun DayContent(
val fadeSpec = rememberCalendarFadeSpec()
// Shared by every page and the gutter, so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
// into the day view (i.e. when arriving from the month/week view).
// survives day-to-day swipes. Positioned once on entry into the day view:
// at the visible hours' start if set (#322), otherwise noon.
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
OpenTimelineScroll(scrollState, LocalTimelineZoom.current)
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
@@ -712,6 +709,7 @@ private fun Timeline(
date = state.date,
today = state.today,
hourHeight = hourHeight,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
@@ -747,6 +745,7 @@ private fun DayColumnCard(
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
@@ -766,6 +765,8 @@ private fun DayColumnCard(
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -793,6 +794,7 @@ private fun DayColumnCard(
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
@@ -854,6 +856,11 @@ private fun DayColumnCard(
if (date == today) {
NowLine(date = date, hourHeight = hourHeight)
}
OutOfViewEdgeBars(
edges = edges,
scrollState = scrollState,
dark = dark,
)
}
}
}
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize
@@ -64,6 +65,7 @@ data class SettingsUiState(
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
/** How tall an hour is drawn in the week and day timelines (#56). */
val timelineScale: TimelineScale = TimelineScale.Regular,
val timelineHours: TimelineHours = TimelineHours(),
/** Order of the views in the navigation drawer (#24); every view is always listed. */
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
/** Optional event-form fields shown by default (rest behind "more fields"). */
@@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
@@ -180,9 +181,11 @@ class SettingsViewModel @Inject constructor(
prefs.drawerViewOrder,
prefs.monthViewStyle,
prefs.widgetSize,
prefs.timelineScale,
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
combine(prefs.timelineScale, prefs.timelineHours, ::Pair),
) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) ->
ViewCustomization(
quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours,
)
},
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
@@ -209,6 +212,7 @@ class SettingsViewModel @Inject constructor(
monthViewStyle = misc.viewCustomization.monthViewStyle,
widgetSize = misc.viewCustomization.widgetSize,
timelineScale = misc.viewCustomization.timelineScale,
timelineHours = misc.viewCustomization.timelineHours,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -328,6 +332,7 @@ class SettingsViewModel @Inject constructor(
val monthViewStyle: MonthViewStyle,
val widgetSize: WidgetSize,
val timelineScale: TimelineScale,
val timelineHours: TimelineHours,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
@@ -626,6 +631,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimelineScale(scale) }
}
fun setTimelineHours(hours: TimelineHours) {
viewModelScope.launch { prefs.setTimelineHours(hours) }
}
fun setDrawerViewOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
}
@@ -34,9 +34,12 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.label
@@ -74,6 +77,9 @@ internal fun ViewsScreen(
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showTimelineScale by remember { mutableStateOf(false) }
var showMultiDayCount by remember { mutableStateOf(false) }
var showHoursStart by remember { mutableStateOf(false) }
var showHoursEnd by remember { mutableStateOf(false) }
val hours = state.timelineHours
CollapsingScaffold(
title = stringResource(R.string.settings_section_views),
@@ -170,10 +176,49 @@ internal fun ViewsScreen(
)
GroupedRow(
title = stringResource(R.string.settings_timeline_scale),
summary = stringResource(state.timelineScale.labelRes),
summary = if (hours.enabled) {
stringResource(R.string.settings_timeline_scale_by_visible_hours)
} else {
stringResource(state.timelineScale.labelRes)
},
position = Position.Middle,
onClick = { showTimelineScale = true },
dimmed = hours.enabled,
onClick = if (hours.enabled) null else ({ showTimelineScale = true }),
)
GroupedRow(
title = stringResource(R.string.settings_visible_hours),
summary = if (hours.enabled) {
stringResource(
R.string.settings_visible_hours_on_summary,
hourLabel(hours.startHour),
hourLabel(hours.endHour),
)
} else {
stringResource(R.string.settings_visible_hours_summary)
},
position = Position.Middle,
trailing = {
Switch(
checked = hours.enabled,
onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) },
)
},
onClick = { viewModel.setTimelineHours(hours.copy(enabled = !hours.enabled)) },
)
if (hours.enabled) {
GroupedRow(
title = stringResource(R.string.settings_visible_hours_start),
summary = hourLabel(hours.startHour),
position = Position.Middle,
onClick = { showHoursStart = true },
)
GroupedRow(
title = stringResource(R.string.settings_visible_hours_end),
summary = hourLabel(hours.endHour),
position = Position.Middle,
onClick = { showHoursEnd = true },
)
}
GroupedRow(
title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary),
@@ -378,6 +423,30 @@ internal fun ViewsScreen(
onDismiss = { showTimelineScale = false },
)
}
if (showHoursStart) {
OptionPicker(
title = stringResource(R.string.settings_visible_hours_start),
header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) },
predictiveBack = true,
options = TimelineHours.startOptions(hours.endHour),
selected = hours.startHour,
label = { hourLabel(it) },
onSelect = { viewModel.setTimelineHours(hours.copy(startHour = it)) },
onDismiss = { showHoursStart = false },
)
}
if (showHoursEnd) {
OptionPicker(
title = stringResource(R.string.settings_visible_hours_end),
header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) },
predictiveBack = true,
options = TimelineHours.endOptions(hours.startHour),
selected = hours.endHour,
label = { hourLabel(it) },
onSelect = { viewModel.setTimelineHours(hours.copy(endHour = it)) },
onDismiss = { showHoursEnd = false },
)
}
if (showAgendaScreenRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
@@ -475,3 +544,8 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode)
@Composable
private fun multiDayLabel(count: Int): String =
pluralStringResource(R.plurals.view_multi_day, count, count)
/** A whole hour in the app's clock convention; 24 reads as the end of the day. */
@Composable
private fun hourLabel(hour: Int): String =
formatMinuteOfDay(hour * 60, LocalUse24HourFormat.current, currentLocale())
@@ -132,6 +132,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars
import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
@@ -141,6 +143,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.OpenTimelineScroll
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
@@ -154,7 +157,6 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -414,16 +416,11 @@ private fun WeekContent(
val fadeSpec = rememberCalendarFadeSpec()
// Shared by every page and the gutter, so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
// noon once, on first entry into the week view (i.e. when arriving from the
// month/day view), not on every swipe.
// survives week-to-week swipes (e.g. 18:00 stays centred). Positioned once
// on entry into the week view, not on every swipe: at the visible hours'
// start if set (#322), otherwise noon.
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
OpenTimelineScroll(scrollState, LocalTimelineZoom.current)
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
@@ -1002,6 +999,7 @@ private fun Timeline(
date = day,
today = state.today,
hourHeight = hourHeight,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
@@ -1023,6 +1021,7 @@ private fun DayColumnCard(
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
@@ -1042,6 +1041,8 @@ private fun DayColumnCard(
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -1069,6 +1070,7 @@ private fun DayColumnCard(
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
detectTapGestures { offset ->
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
@@ -1130,6 +1132,11 @@ private fun DayColumnCard(
if (date == today) {
NowLine(date = date, hourHeight = hourHeight)
}
OutOfViewEdgeBars(
edges = edges,
scrollState = scrollState,
dark = dark,
)
}
}
}
+13
View File
@@ -67,6 +67,10 @@
<!-- Day view (S3) -->
<string name="day_today_action">Today</string>
<!-- Week and day timelines — edge bar for events scrolled out of view (#188) -->
<string name="timeline_earlier_events">Earlier events</string>
<string name="timeline_later_events">Later events</string>
<!-- Event detail screen (S4) -->
<string name="event_detail_back">Back</string>
<string name="event_detail_edit">Edit</string>
@@ -349,6 +353,7 @@
<string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Dismiss</string>
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
<string name="reminder_day_today">Today</string>
<string name="reminder_day_tomorrow">Tomorrow</string>
<string name="reminder_day_yesterday">Yesterday</string>
@@ -478,6 +483,7 @@
<string name="settings_multi_day_count">Multi-day view</string>
<string name="settings_multi_day_count_hint">How many days the multi-day view shows side by side. Today opens in the first column.</string>
<string name="settings_timeline_scale">Hour height</string>
<string name="settings_timeline_scale_by_visible_hours">Set by Visible hours</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>
<string name="timeline_scale_fit_day_summary">All 24 hours on one screen, no scrolling</string>
@@ -489,6 +495,13 @@
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</string>
<string name="timeline_scale_custom">Custom</string>
<string name="timeline_scale_custom_summary">The height you pinched the timeline to</string>
<string name="settings_visible_hours">Visible hours</string>
<string name="settings_visible_hours_summary">Fit a range of hours to the screen when week, multi-day or day view opens</string>
<string name="settings_visible_hours_on_summary">%1$s – %2$s fills the screen when a view opens</string>
<string name="settings_visible_hours_start">From</string>
<string name="settings_visible_hours_end">Until</string>
<string name="settings_visible_hours_start_hint">The hour at the top of the screen when week, multi-day or day view opens. Earlier hours are still a scroll away.</string>
<string name="settings_visible_hours_end_hint">The hour at the bottom of the screen when a view opens. Later hours are still a scroll away, and pinching zooms until the view is opened again.</string>
<string name="settings_dim_completed">Dim completed events</string>
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import kotlinx.coroutines.flow.first
@@ -125,6 +126,24 @@ class SettingsPrefsTest {
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Comfortable)
}
@Test
fun `visible hours default off and round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 7, 22))
prefs.setTimelineHours(TimelineHours(enabled = true, 6, 20))
assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 6, 20))
// Switching off keeps the chosen hours for next time.
prefs.setTimelineHours(TimelineHours(enabled = false, 6, 20))
assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 6, 20))
}
@Test
fun `visible hours are stored valid`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setTimelineHours(TimelineHours(enabled = true, 23, 22))
assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 20, 22))
}
@Test
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
@@ -38,6 +38,7 @@ class ReminderTimeTextTest {
is24Hour = is24Hour,
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
todayLabel = "Today",
tomorrowLabel = "Tomorrow",
yesterdayLabel = "Yesterday",
)
@@ -130,38 +131,74 @@ class ReminderTimeTextTest {
}
@Test
fun `all-day single day shows one date, read in UTC`() {
fun `all-day single day today reads as today, read in UTC`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day.
// the previous evening in Los Angeles — naive local reading would shift the day.
zone = ZoneId.of("America/Los_Angeles"),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
assertThat(text).isEqualTo("Today")
}
@Test
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
fun `all-day event tomorrow reads as tomorrow`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("12.06.2026")
assertThat(text).isEqualTo("Tomorrow")
}
@Test
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
fun `all-day event later this week reads as the short weekday`() {
// 2026-06-11 is a Thursday; Saturday the 13th shares its week.
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
beginMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 14)),
isAllDay = true,
locale = Locale.US,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Sat")
}
@Test
fun `all-day event next week falls back to the exact date`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 16)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 17)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
assertThat(text).isEqualTo("16.06.2026")
}
@Test
fun `all-day multi-day reads relative start and the last covered day`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 14)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Tomorrow – 13.06.2026")
}
@Test
fun `all-day multi-day further out keeps both dates`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 7, 1)),
endMillis = utcMidnight(LocalDate.of(2026, 7, 3)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("01.07.2026 – 02.07.2026")
}
@Test
@@ -173,6 +210,6 @@ class ReminderTimeTextTest {
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
assertThat(text).isEqualTo("Today")
}
}
@@ -156,4 +156,5 @@ class EventToneTest {
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
.isEqualTo(0xFF000000.toInt())
}
}
@@ -0,0 +1,147 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** Which events a column's edge bars point at, and where a tap scrolls (#188). */
class OutOfViewEdgesTest {
private fun block(id: Long, startMin: Int, endMin: Int) = TimedBlock(
event = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event $id",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
),
startMin = startMin,
endMin = endMin,
lane = 0,
laneCount = 1,
)
private val early = block(1, 7 * 60, 8 * 60)
private val breakfast = block(2, 8 * 60, 9 * 60)
private val lunch = block(3, 12 * 60, 13 * 60)
private val dinner = block(4, 19 * 60, 20 * 60)
private val late = block(5, 21 * 60, 22 * 60)
// Viewport 10:00–17:00.
private fun outOf(vararg blocks: TimedBlock) =
outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f)
@Test
fun `the nearest hidden event on each side is picked`() {
val events = outOf(early, breakfast, lunch, dinner, late)
assertThat(events.above).isEqualTo(breakfast)
assertThat(events.below).isEqualTo(dinner)
}
@Test
fun `an event in view points nowhere`() {
assertThat(outOf(lunch)).isEqualTo(OutOfViewEvents.None)
}
@Test
fun `an event cut by the edge is not out of view`() {
val straddling = block(6, 9 * 60, 11 * 60)
assertThat(outOf(straddling).above).isNull()
}
@Test
fun `an event disappears from the edge as soon as a sliver enters view`() {
val sliver = block(6, 9 * 60, 10 * 60 + 5)
assertThat(outOf(sliver).above).isNull()
val tip = block(7, 17 * 60 - 5, 18 * 60)
assertThat(outOf(tip).below).isNull()
val justAbove = block(8, 9 * 60, 10 * 60)
val justBelow = block(9, 17 * 60, 18 * 60)
assertThat(outOf(justAbove).above).isEqualTo(justAbove)
assertThat(outOf(justBelow).below).isEqualTo(justBelow)
}
@Test
fun `the bar fades before its event reaches the viewport`() {
assertThat(edgeBarAlpha(gapPx = 24f, fadeLeadPx = 24f)).isEqualTo(1f)
assertThat(edgeBarAlpha(gapPx = 12f, fadeLeadPx = 24f)).isEqualTo(0.5f)
assertThat(edgeBarAlpha(gapPx = 0f, fadeLeadPx = 24f)).isEqualTo(0f)
assertThat(edgeBarAlpha(gapPx = -1f, fadeLeadPx = 24f)).isEqualTo(0f)
}
@Test
fun `a column scrolled to its top points at nothing above it`() {
val first = block(6, 0, 30)
val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f)
assertThat(events.above).isNull()
}
@Test
fun `a short event is judged by the height it renders at`() {
// Five minutes ending at 09:58 still draws 26 minutes tall, past 10:00.
val blip = block(6, 9 * 60 + 53, 9 * 60 + 58)
assertThat(outOf(blip).above).isNull()
}
@Test
fun `an event above scrolls to its start less the margin`() {
val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
assertThat(target).isEqualTo(780)
}
@Test
fun `an event below that fits lands whole at the bottom`() {
val target = revealScrollTarget(dinner, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
// Its end, 2000, plus the margin, sits at the viewport's bottom.
assertThat(target).isEqualTo(1320)
}
@Test
fun `an event below taller than the viewport lands with its start at the top`() {
val long = block(6, 12 * 60, 22 * 60)
val target = revealScrollTarget(long, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
assertThat(target).isEqualTo(1180)
}
@Test
fun `the target stays within the scroll range`() {
val first = block(6, 0, 30)
assertThat(
revealScrollTarget(first, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700),
).isEqualTo(0)
val last = block(7, 23 * 60, 24 * 60)
assertThat(
revealScrollTarget(last, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700),
).isEqualTo(1700)
}
// Viewport 1000..1700 px with both bars showing, 40 px of reach.
private fun tapped(y: Float, events: OutOfViewEvents = OutOfViewEvents(breakfast, dinner)) =
tappedEdge(events, y, viewportTop = 1000f, viewportPx = 700, reachPx = 40f)
@Test
fun `a tap near an edge with a bar hits that bar`() {
assertThat(tapped(1010f)).isEqualTo(ViewportEdge.Top)
assertThat(tapped(1040f)).isEqualTo(ViewportEdge.Top)
assertThat(tapped(1690f)).isEqualTo(ViewportEdge.Bottom)
}
@Test
fun `a tap past the bar's reach is left to the column`() {
assertThat(tapped(1041f)).isNull()
assertThat(tapped(1350f)).isNull()
assertThat(tapped(1659f)).isNull()
}
@Test
fun `a tap near an edge without a bar is left to the column`() {
assertThat(tapped(1010f, OutOfViewEvents(null, dinner))).isNull()
assertThat(tapped(1690f, OutOfViewEvents(breakfast, null))).isNull()
}
}
@@ -0,0 +1,115 @@
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 TimelineHoursTest {
private val viewport = 600.dp
@Test
fun `the chosen range exactly fills the viewport`() {
// 07–22 is 15 hours: 600dp / 15 = 40dp an hour.
assertThat(TimelineScale.FitHours(7, 22).hourHeight(viewport)).isEqualTo(40.dp)
}
@Test
fun `the whole day fits like FitDay`() {
assertThat(TimelineScale.FitHours(0, 24).hourHeight(viewport))
.isEqualTo(TimelineScale.FitDay.hourHeight(viewport))
}
@Test
fun `a short range on a tall screen stops at the pinch ceiling`() {
assertThat(TimelineScale.FitHours(9, 11).hourHeight(2000.dp)).isEqualTo(MAX_PINCH_HOUR_HEIGHT)
}
@Test
fun `ranges are held to the day and the minimum span`() {
assertThat(TimelineHours.of(true, -3, 30)).isEqualTo(TimelineHours(true, 0, 24))
assertThat(TimelineHours.of(true, 23, 22)).isEqualTo(TimelineHours(true, 20, 22))
assertThat(TimelineHours.of(true, 5, 1)).isEqualTo(TimelineHours(true, 0, 2))
}
@Test
fun `the pickers only offer valid pairings`() {
assertThat(TimelineHours.startOptions(22)).isEqualTo((0..20).toList())
assertThat(TimelineHours.endOptions(7)).isEqualTo((9..24).toList())
}
@Test
fun `opening scrolls to the start hour once the layout is at the fitted height`() {
// 40px an hour over a 600px viewport: 960px of content, 360px of scroll.
assertThat(startHourScroll(maxValue = 360, viewportPx = 600, startHour = 7, hourPx = 40f))
.isEqualTo(280)
}
@Test
fun `a layout still at the previous scale is waited out`() {
// Still 56px an hour: 1344 - 600 = 744 of scroll.
assertThat(startHourScroll(maxValue = 744, viewportPx = 600, startHour = 7, hourPx = 40f))
.isNull()
// Not laid out at all yet.
assertThat(startHourScroll(maxValue = Int.MAX_VALUE, viewportPx = 0, startHour = 7, hourPx = 40f))
.isNull()
}
@Test
fun `the whole day needs no scroll`() {
assertThat(startHourScroll(maxValue = 0, viewportPx = 600, startHour = 0, hourPx = 25f))
.isEqualTo(0)
}
@Test
fun `a fitted range round-trips through storage`() {
val scale = TimelineScale.FitHours(6, 20)
assertThat(parseTimelineScale(scale.storageValue())).isEqualTo(scale)
assertThat(parseTimelineScale("hours:x-2")).isEqualTo(TimelineScale.Regular)
}
@Test
fun `opening with visible hours on replaces a pinched scale`() {
val persisted = mutableListOf<TimelineScale>()
val zoom = TimelineZoom(TimelineScale.custom(90.dp)) { persisted += it }
zoom.adoptHours(TimelineHours(true, 7, 22))
assertThat(zoom.open()).isEqualTo(7)
assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22))
// A pinch overrides it for the visit but is not saved over the preference.
zoom.beginPinch()
zoom.pinchTo(100.dp)
zoom.endPinch()
assertThat(zoom.scale).isEqualTo(TimelineScale.custom(100.dp))
assertThat(persisted).isEmpty()
// Opening again fits the range again.
zoom.open()
assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22))
}
@Test
fun `a late stored scale does not undo the fit, and switching off restores it`() {
val zoom = TimelineZoom(TimelineScale.Regular) {}
zoom.adoptHours(TimelineHours(true, 7, 22))
zoom.open()
zoom.adopt(TimelineScale.Comfortable)
assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22))
zoom.adoptHours(TimelineHours(false, 7, 22))
assertThat(zoom.open()).isNull()
assertThat(zoom.scale).isEqualTo(TimelineScale.Comfortable)
}
@Test
fun `with visible hours off a pinch is still saved`() {
val persisted = mutableListOf<TimelineScale>()
val zoom = TimelineZoom(TimelineScale.Regular) { persisted += it }
assertThat(zoom.open()).isNull()
zoom.beginPinch()
zoom.pinchTo(70.dp)
zoom.endPinch()
assertThat(persisted).containsExactly(TimelineScale.custom(70.dp))
}
}