Compare commits

..
Author SHA1 Message Date
makiolaj e2b35b3fac Keep timeline events in the right repeated hour on DST fall-back day (#387)
On the day the clocks go back, 02:00–03:00 happens twice. Event times now
remember which of the two they mean, so moving, resizing, editing and saving
keep them in the right one.

- The week and day timeline place blocks by clock time, so events after a DST
  change no longer sit an hour off.
- An event ending in the repeated hour is drawn up to the end of that hour,
  and its label shows its real end time.
- Drags, drops and resizes keep the event's real length across a clock
  change; a resize only changes the edge being dragged.
- A drop onto a repeated time lands on the first one, unless the event is
  already in the second one.
- The editor, event details and the "local time" line add the zone
  abbreviation to a repeated time so the two can be told apart.
- Saving checks and writes the real start and end times, so a span ending in
  the repeated hour is valid and switching between the two is saved.

Closes #387
2026-10-12 23:24:23 +02:00
34 changed files with 789 additions and 621 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: light
runs-on: docker
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
@@ -41,7 +41,7 @@ jobs:
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: gradle
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: light
runs-on: docker
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
@@ -85,7 +85,7 @@ jobs:
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: gradle
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
-2
View File
@@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
### Fixed
@@ -1860,4 +1859,3 @@ automatically, with zero telemetry and no internet permission.
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2230003
versionName = "2.23.0-beta.3"
versionCode = 2230002
versionName = "2.23.0-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.resolveIn
import kotlinx.datetime.toKotlinTimeZone
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
@@ -48,8 +50,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
} else {
val writeZone = writeZone(zone)
EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
dtStartMillis = start.resolveIn(writeZone.toKotlinTimeZone(), startOffset).toEpochMilliseconds(),
dtEndMillis = end.resolveIn(writeZone.toKotlinTimeZone(), endOffset).toEpochMilliseconds(),
timezone = writeZone.id,
)
}
@@ -181,12 +183,14 @@ internal fun buildEventUpdateValues(
// A zone change counts as a time change even when the wall-clock is
// untouched: the same 09:00 in another zone is a different instant, so
// DTSTART has to move with it.
// DTSTART has to move with it. So does a switch between the two passes of
// the fall-back hour, which only the resolved instants tell apart (#387).
val timesChanged = updated.start != original.start ||
updated.end != original.end ||
updated.isAllDay != original.isAllDay ||
updated.rrule != original.rrule ||
updated.timezone != original.timezone
updated.timezone != original.timezone ||
updated.toWriteTimes(zone) != original.toWriteTimes(zone)
if (!timesChanged) return@buildMap
val newTimes = updated.toWriteTimes(zone)
@@ -4,7 +4,8 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@@ -36,6 +37,13 @@ data class EventForm(
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
*/
val timezone: String? = null,
/**
* The UTC offsets [start]/[end] were read at, when they came from real
* instants. Only a wall-clock time the fall-back hour repeats needs them:
* both of its instants share one [LocalDateTime] (#387).
*/
val startOffset: UtcOffset? = null,
val endOffset: UtcOffset? = null,
val location: String = "",
val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */
@@ -145,16 +153,15 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
}
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
val begin = Instant.fromEpochMilliseconds(beginMillis)
val finish = Instant.fromEpochMilliseconds(endMillis)
val (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(TimeZone.UTC).date
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
.toLocalDateTime(TimeZone.UTC).date
val startDate = begin.toLocalDateTime(TimeZone.UTC).date
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
begin.toLocalDateTime(formZone) to finish.toLocalDateTime(formZone)
}
return EventForm(
calendarId = instance.calendarId,
@@ -163,6 +170,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
start = start,
end = end,
timezone = pinnedZone,
startOffset = if (instance.isAllDay) null else formZone.offsetAt(begin),
endOffset = if (instance.isAllDay) null else formZone.offsetAt(finish),
location = instance.location.orEmpty(),
description = description.orEmpty(),
reminders = reminders.map { it.minutes }.distinct().sorted(),
@@ -221,20 +230,18 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
)
/**
* The form's times as they land in [target] — what a pinned event's wall-clock
* actually means where the user is standing. Null when there's nothing to
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
* itself, an all-day event (no zone), or an unparseable pinned zone.
* Whether this form's times mean something else in [target] — a timed event
* pinned to another zone. False when there's nothing to disambiguate: an
* unpinned event (already in [target]), one pinned to [target] itself, an
* all-day event (no zone), or an unparseable pinned zone.
*
* The form edits a pinned event in its own zone, so this is what lets the UI
* show the other side of the pair rather than making the user do the arithmetic.
* The form edits a pinned event in its own zone, so this is what tells the UI
* to show the other side of the pair rather than making the user do the arithmetic.
*/
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
if (isAllDay) return null
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
if (pinned.id == target.id) return null
return start.toInstant(pinned).toLocalDateTime(target) to
end.toInstant(pinned).toLocalDateTime(target)
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
if (isAllDay) return false
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false
return pinned.id != target.id
}
/**
@@ -254,9 +261,19 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
}
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
/**
* What stops this form from saving. A timed span is checked as instants in the
* form's zone (else [deviceZone]): one ending in the fall-back hour's repeat can
* end on an earlier wall clock than it starts (#387).
*/
fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
if (calendarId == null) add(EventFormProblem.NoCalendar)
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
val endsTooEarly = if (isAllDay) {
end.date < start.date
} else {
val zone = resolvedZone(deviceZone)
endInstant(zone) < startInstant(zone)
}
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
// An UNTIL before the first day would make the provider generate zero
// occurrences — the event would silently vanish from every view.
@@ -3,10 +3,14 @@ package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.offsetAt
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
/**
* The zone this form's wall-clock times mean, matching what the data layer
@@ -15,6 +19,43 @@ import kotlin.time.Instant
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
/**
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
* it and that is one of its two offsets — otherwise the earlier one (#387).
*/
fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant {
val kept = preferred?.let { toInstant(it) }
return kept?.takeIf { it.toLocalDateTime(zone) == this } ?: toInstant(zone)
}
/** Whether this instant's wall clock in [zone] is one the fall-back hour repeats. */
fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
val zid = zone.toJavaZoneId()
return zid.rules.getValidOffsets(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid)).size > 1
}
/** The form's start as an instant in [zone], keeping its [EventForm.startOffset]. */
fun EventForm.startInstant(zone: TimeZone): Instant = start.resolveIn(zone, startOffset)
/** The form's end as an instant in [zone], keeping its [EventForm.endOffset]. */
fun EventForm.endInstant(zone: TimeZone): Instant = end.resolveIn(zone, endOffset)
/** The form's timed span set to these instants, read in [zone] with their offsets. */
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm = copy(
start = start.toLocalDateTime(zone),
end = end.toLocalDateTime(zone),
startOffset = zone.offsetAt(start),
endOffset = zone.offsetAt(end),
)
/**
* The form with its end picked by hand. A picked wall-clock time can't say which
* pass of a repeated hour it means, so it drops [EventForm.endOffset] and takes
* the earlier — unless it is the end the form already has.
*/
fun EventForm.withEnd(end: LocalDateTime): EventForm =
if (end == this.end) this else copy(end = end, endOffset = null)
/**
* The form moved so it starts at [newStart], keeping its **instant** duration —
* a recurring event's length travels to the provider as `DURATION`, so keeping
@@ -26,11 +67,8 @@ fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(
start = newStart.toLocalDateTime(zone),
end = (newStart + span).toLocalDateTime(zone),
)
val span = endInstant(zone) - startInstant(zone)
return withTimes(newStart, newStart + span, zone)
}
/**
@@ -48,16 +86,13 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
)
}
val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
val span = endInstant(zone) - startInstant(zone)
val startAt = newStart.resolveIn(zone, startOffset)
return withTimes(startAt, startAt + span, zone)
}
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
val zone = resolvedZone(deviceZone)
return copy(
start = newStart.toLocalDateTime(zone),
end = newEnd.toLocalDateTime(zone),
)
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
}
@@ -4,7 +4,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
@@ -35,7 +35,15 @@ fun buildInsertEventForm(
zone: TimeZone,
now: Instant,
): EventForm {
val (start, end) = if (isAllDay) {
val timed = if (isAllDay) {
null
} else {
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
?: (startAt + 1.hours)
startAt to endAt
}
val (start, end) = if (timed == null) {
// All-day provider times are UTC midnights with an exclusive end; show
// the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm).
@@ -48,14 +56,7 @@ fun buildInsertEventForm(
?: startDate
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else {
val startTime = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?: nextFullHour(now, zone)
val endTime = endMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?.takeIf { it >= startTime }
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
startTime to endTime
timed.first.toLocalDateTime(zone) to timed.second.toLocalDateTime(zone)
}
return EventForm(
calendarId = null,
@@ -63,6 +64,8 @@ fun buildInsertEventForm(
isAllDay = isAllDay,
start = start,
end = end,
startOffset = timed?.let { zone.offsetAt(it.first) },
endOffset = timed?.let { zone.offsetAt(it.second) },
location = location.orEmpty(),
description = description.orEmpty(),
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
@@ -71,8 +74,7 @@ fun buildInsertEventForm(
)
}
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
private fun nextFullHour(now: Instant): Instant {
val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
}
@@ -157,12 +157,18 @@ fun timeZoneOptionOf(
* the offset isn't stated twice in ICU's spelling and ours.
*/
fun zoneDescriptor(option: TimeZoneOption): String {
val name = zoneAbbreviation(option)
val statesOffset = option.shortName == "UTC" || option.shortName.startsWith("GMT")
return if (statesOffset) name else "$name · ${formatGmtOffset(option.offsetMinutes)}"
}
/**
* [option]'s short name on its own — "CEST", "UTC" — with an offset-shaped one
* normalised to our GMT form, as [zoneDescriptor] spells it.
*/
fun zoneAbbreviation(option: TimeZoneOption): String {
val abbrev = option.shortName
return when {
abbrev == "UTC" -> "UTC"
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
}
return if (abbrev.startsWith("GMT")) formatGmtOffset(option.offsetMinutes) else abbrev
}
/**
@@ -5,6 +5,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime
/**
@@ -34,6 +35,8 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
isAllDay = isAllDay,
start = start,
end = end,
startOffset = if (isAllDay) null else zone.offsetAt(this.start),
endOffset = if (isAllDay) null else zone.offsetAt(this.end),
location = location.orEmpty(),
description = description.orEmpty(),
reminders = semanticReminderMinutes(),
@@ -239,18 +239,12 @@ fun CalendarHost(
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
// A drafted block's end (#153), in minutes from the start day's midnight.
var createEndMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateEndMinutes by remember { mutableStateOf<Int?>(null) }
val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, endMinutes ->
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
heldCreateIso = date.toString()
createDateIso = date.toString()
heldCreateMinutes = startMinutes
createStartMinutes = startMinutes
heldCreateEndMinutes = endMinutes
createEndMinutes = endMinutes
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> onCreateSpan(date, startMinutes, null) }
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
// recurring events the form itself asks for the write scope at save
@@ -386,8 +380,6 @@ fun CalendarHost(
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
heldCreateEndMinutes = null
createEndMinutes = null
onWidgetNavConsumed()
}
null -> {}
@@ -591,7 +583,6 @@ fun CalendarHost(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
)
ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay,
@@ -680,7 +671,6 @@ fun CalendarHost(
EventEditScreen(
initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
@@ -278,23 +278,12 @@ data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y:
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
* grid lines follow it instantly — so the tween stands down for the gesture
* rather than leaving the blocks trailing the ruler they are measured against.
* A [landing] block, under a dropped copy, moves without the spring's bounce.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun animatedBlockPlacement(
xFraction: Float,
widthFraction: Float,
y: Dp,
height: Dp,
landing: Boolean = false,
): BlockPlacement {
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
val dpSpec: FiniteAnimationSpec<Dp> = when {
still -> snap()
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
else -> MaterialTheme.motionScheme.fastSpatialSpec()
}
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
@@ -69,15 +69,9 @@ class ScreenActions(
/**
* An event a long press left selected (#141), which the host answers with the
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
* [create] is set for a block drafted on an empty slot (#153), which has no
* event yet, so the bar offers to create one.
*/
@Stable
class EventSelection(
val event: EventInstance,
val dismiss: () -> Unit,
val create: (() -> Unit)? = null,
)
class EventSelection(val event: EventInstance, val dismiss: () -> Unit)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable
@@ -120,26 +114,10 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
@Composable
fun PublishSelection(
event: EventInstance?,
busy: Boolean,
compact: Boolean,
onDismiss: () -> Unit,
onCreate: (() -> Unit)? = null,
) {
fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val currentCreate by rememberUpdatedState(onCreate)
val drafted = onCreate != null
val selection = remember(event, drafted) {
event?.let {
EventSelection(
event = it,
dismiss = { currentDismiss() },
create = if (drafted) ({ currentCreate?.invoke() }) else null,
)
}
}
val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
val host = slot.host
val current = slot.current
DisposableEffect(host, selection, current) {
@@ -73,8 +73,7 @@ fun rememberEventDragSource(
* The same pickup, for a surface that carries many draggable pieces rather than
* being one itself — the month grid, whose chips are covered by a full-bleed tap
* layer. [onPickUp] receives the press position local to this node and answers
* whether anything is there; false abandons the gesture. [skipClaimedDown] leaves
* a press a descendant already consumed alone.
* whether anything is there; false abandons the gesture.
*/
@Composable
fun rememberDragSurface(
@@ -84,8 +83,6 @@ fun rememberDragSurface(
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)? = null,
skipClaimedDown: Boolean = false,
): Modifier = rememberPickUpSurface(
enabled = enabled,
key = key,
@@ -96,8 +93,7 @@ fun rememberDragSurface(
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
onRelease = onRelease,
skipClaimedDown = skipClaimedDown,
onRelease = null,
)
@Composable
@@ -110,7 +106,6 @@ private fun rememberPickUpSurface(
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)?,
skipClaimedDown: Boolean = false,
): Modifier {
if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -130,8 +125,6 @@ private fun rememberPickUpSurface(
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
// A child that took the press, such as a resize handle, keeps it.
if (skipClaimedDown && down.isConsumed) return@awaitEachGesture
val origin = down.position
val immediate = currentImmediate
// Null: held still. True: an immediate pickup past slop. False: abandoned.
@@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
@@ -54,26 +52,6 @@ fun EventSelectionBar(
}
}
/** A drafted block's bar (#153): create the event, or let the draft go. */
@Composable
fun DraftSelectionBar(onCreate: () -> Unit, onDiscard: () -> Unit, modifier: Modifier = Modifier) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shadowElevation = 6.dp,
modifier = modifier.height(FabSize),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
BarAction(Icons.Default.Close, R.string.event_draft_discard, onDiscard)
BarAction(Icons.Default.Check, R.string.event_draft_create, onCreate)
}
}
}
@Composable
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
IconButton(onClick = onClick) {
@@ -47,23 +47,6 @@ fun rememberFabSelection(
val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null
selection.create?.let { create ->
return remember(selection, expanded, chipShown) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
DraftSelectionBar(
onCreate = { currentWrite { create() } },
onDiscard = selection.dismiss,
)
},
)
}
}
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
@@ -195,20 +195,15 @@ class OutOfViewEdges internal constructor(
}
}
/** Whether a press at [y] (content px) lands on an edge bar (#153). */
fun covers(y: Float): Boolean = edgeAt(y) != null
private fun edgeAt(y: Float) = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
)
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean {
val edge = edgeAt(y) ?: return false
val edge = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
) ?: return false
reveal(edge)
return true
}
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resizedTo
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
@@ -329,7 +331,7 @@ class RescheduleViewModel @Inject constructor(
// The UNTIL check is deferred to the write, which knows how far the move
// reaches and so which date has to clear it.
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
_outcome.value = MoveOutcome.Failed
return null
}
@@ -495,8 +497,7 @@ class RescheduleViewModel @Inject constructor(
private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated
return form.end.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
.toEpochMilliseconds()
return form.endInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds()
}
/**
@@ -508,8 +509,7 @@ class RescheduleViewModel @Inject constructor(
return if (form.isAllDay) {
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
} else {
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
.toEpochMilliseconds()
form.startInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds()
}
}
@@ -40,9 +40,7 @@ fun SelectionTopBar(selection: EventSelection) {
IconButton(onClick = selection.dismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(
if (selection.create != null) R.string.event_draft_discard else R.string.event_selection_close,
),
contentDescription = stringResource(R.string.event_selection_close),
)
}
},
@@ -1,7 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import de.jeanlucmakiola.calendula.domain.inRepeatedHour
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneAbbreviation
import kotlinx.datetime.TimeZone
import java.time.LocalTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import java.time.format.DateTimeFormatter
import java.util.Locale
@@ -68,3 +74,15 @@ fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
}
private const val MINUTES_PER_DAY = 1_440
/**
* [formatted] — [instant]'s time in [zone] — with the zone's abbreviation after
* it ("2:30 AM CEST") when the fall-back hour repeats that time, the one case
* where the time alone doesn't say which instant it is (#387).
*/
fun withRepeatedHourZone(formatted: String, instant: Instant, zone: TimeZone, locale: Locale): String {
if (!instant.inRepeatedHour(zone)) return formatted
val option = timeZoneOptionOf(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion)
?: return formatted
return "$formatted ${zoneAbbreviation(option)}"
}
@@ -44,10 +44,13 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.inRepeatedHour
import de.jeanlucmakiola.calendula.domain.resolveIn
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.minutesPast
import de.jeanlucmakiola.calendula.ui.week.wallClockEndMinutes
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
@@ -55,6 +58,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.offsetAt
import kotlinx.datetime.plus
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -62,7 +66,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration
import kotlin.time.Instant
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
@@ -271,41 +275,6 @@ data class TimelineDrop(
val clipOffsetMin: Int = 0,
)
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
private const val DRAFT_ID = Long.MIN_VALUE
/** Whether this is the stand-in for a block drafted on an empty slot, not a real event. */
val EventInstance.isDraft: Boolean get() = instanceId == DRAFT_ID
/** A drafted block's stand-in event, [minutes] long from [start]. */
fun draftEvent(title: String, color: Int, start: Instant, minutes: Int): EventInstance = EventInstance(
instanceId = DRAFT_ID,
eventId = DRAFT_ID,
calendarId = DRAFT_ID,
title = title,
start = start,
end = start + minutes.minutes,
isAllDay = false,
color = color,
location = null,
)
/** Where a long press [minutes] down the column starts its draft, snapped down to the slot. */
internal fun draftStartMin(minutes: Float): Int =
(minutes / DRAG_SNAP_MINUTES).toInt().times(DRAG_SNAP_MINUTES)
.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
/** The part of [draft] on [date] as a full-width block, or null if none of it falls there. */
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
val start = draft.start.toLocalDateTime(zone)
val end = draft.end.toLocalDateTime(zone)
// The end is exclusive, so a draft ending at midnight belongs to the day before.
if (start.date > date || end.date < date || (end.date == date && end.time == LocalTime(0, 0))) return null
val startMin = if (start.date < date) 0 else start.time.toSecondOfDay() / 60
val endMin = if (end.date > date) MINUTES_PER_DAY else end.time.toSecondOfDay() / 60
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
}
/** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom }
@@ -428,9 +397,6 @@ class TimelineDragController {
*/
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
/** The selected event, if any — see [TimelineSelection]. */
var selection: TimelineSelection? by mutableStateOf(null)
private set
@@ -441,7 +407,6 @@ class TimelineDragController {
}
fun deselect() {
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
selection = null
selectionCovered = false
}
@@ -547,6 +512,8 @@ class TimelineDragController {
* finger is on; this is what turns where it lands back into an event start.
*/
private var clipOffsetMin = 0
private var dragZone: TimeZone? = null
private var dragDuration = Duration.ZERO
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0
@@ -580,6 +547,7 @@ class TimelineDragController {
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
zone: TimeZone,
titleLines: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
@@ -588,6 +556,8 @@ class TimelineDragController {
if (!isSelected(block)) deselect()
source = block
this.clipOffsetMin = clipOffsetMin
dragZone = zone
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
this.titleLines = titleLines
settling = null
isDragging = true
@@ -601,16 +571,6 @@ class TimelineDragController {
originSlot = drag?.slot
}
/**
* Draft a block on an empty slot and lift it at once (#153). It is selected
* from the start, so it keeps its handles and bar once the finger lets go.
*/
fun beginDraft(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
deselect()
select(block.event)
begin(block, clipOffsetMin = 0, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
}
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
@@ -624,6 +584,8 @@ class TimelineDragController {
resizeSlot = null
source = null
clipOffsetMin = 0
dragZone = null
dragDuration = Duration.ZERO
titleLines = 0
isDragging = false
pointerMoved = false
@@ -691,8 +653,6 @@ class TimelineDragController {
val clipOffset = clipOffsetMin
cancel()
if (landed == null || landed.slot == origin) return null
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
if (landed.edgeOnly && landed.event.isDraft) return null
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so
@@ -716,12 +676,13 @@ class TimelineDragController {
titleLines: Int,
pointerInRoot: Offset,
) {
val span = wallClockSpan(block.event, zone)
val start = block.event.start.toLocalDateTime(zone)
source = block
resizeEdge = edge
resizeDay = span.day
resizeStartMin = span.startMin
resizeEndMin = span.endMin
resizeDay = start.date
resizeStartMin = start.time.toSecondOfDay() / 60
// Wall clock at both ends, as the drop resolves them (DST-safe).
resizeEndMin = wallClockEndMinutes(resizeStartMin, block.event.start, block.event.end, start.date, zone)
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
@@ -731,7 +692,7 @@ class TimelineDragController {
pointer = pointerInRoot
// Measured from the edge's own column: the handle overhangs into the next.
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
val edgeIndex = geometry.days.indexOf(start.date.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
@@ -748,18 +709,35 @@ class TimelineDragController {
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag
val origin = originSlot
val edge = resizeEdge
cancel()
if (landed == null || landed.slot == origin) return null
val drop = TimelineResizeDrop(
event = landed.event,
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
)
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
// Only the held edge moves. The other keeps its own instant rather than
// a round trip through the grid, where an end in the fall-back hour's
// repeat is drawn to that hour's end, not at its own time (#387).
val drop = TimelineResizeDrop(
event = landed.event,
start = if (edge == ResizeEdge.Top) {
wallClockInstant(landed.date, landed.eventStartMin, zone, keepOffsetOf = landed.event.start)
} else {
landed.event.start
},
end = if (edge == ResizeEdge.Bottom) {
wallClockInstant(
landed.date,
landed.eventStartMin + landed.eventSpanMin,
zone,
keepOffsetOf = landed.event.end,
)
} else {
landed.event.end
},
)
settledEnd = drop.end
return drop
}
@@ -882,13 +860,22 @@ class TimelineDragController {
val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
.coerceAtLeast(0)
val floor = dragFloorMin(clipOffsetMin, eventSpan)
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val eventStartMin = startMin - clipOffsetMin
val zone = dragZone ?: return
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val target = days[dayIndex]
val keep = block.event.start
// Measured where it would land: the drop keeps the real duration, which in
// wall clock gains or loses the hour of a DST change it crosses (#387).
val floor = dragFloorMin(
clipOffsetMin,
wallClockSpanMinutes(target, snapped - clipOffsetMin, dragDuration, zone, keep),
)
val held = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) - clipOffsetMin
// A start in the spring-forward gap lands an hour on; show it there.
val eventStartMin = wallClockInstant(target, held, zone, keep).minutesPast(target, zone)
val startMin = (eventStartMin + clipOffsetMin).coerceAtMost(MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val eventSpan = wallClockSpanMinutes(target, eventStartMin, dragDuration, zone, keep)
val visible = -dayIndex..days.lastIndex - dayIndex
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
val slices = onGrid.ifEmpty {
@@ -976,10 +963,7 @@ fun rememberTimelineDragController(): TimelineDragController {
* so the offset has to come back off at the drop (#253).
*/
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
val start = event.start.toLocalDateTime(zone)
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
return minutes.coerceAtLeast(0)
return (startMin - event.start.minutesPast(day, zone)).coerceAtLeast(0)
}
/**
@@ -1001,30 +985,41 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
wallClockInstant(date, startMin - clipOffsetMin, zone)
wallClockInstant(date, startMin - clipOffsetMin, zone, keepOffsetOf = event.start)
/** [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] (DST-safe). */
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant =
wallClockTime(date, minutes).toInstant(zone)
/** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */
fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime {
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = minutes.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60))
/**
* The wall-clock minutes an event lasting [duration] covers when it starts
* [startMin] past [date]'s midnight in [zone] — its real length, give or take a
* DST change in between (#387).
*/
internal fun wallClockSpanMinutes(
date: LocalDate,
startMin: Int,
duration: Duration,
zone: TimeZone,
keepOffsetOf: Instant? = null,
): Int {
val start = wallClockInstant(date, startMin, zone, keepOffsetOf)
return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin
}
/** An event's span as wall-clock minutes from its start day's midnight. */
data class WallClockSpan(val day: LocalDate, val startMin: Int, val endMin: Int)
/** [event]'s [WallClockSpan], its end past 1440 when it ends on a later day (DST-safe). */
fun wallClockSpan(event: EventInstance, zone: TimeZone): WallClockSpan {
val start = event.start.toLocalDateTime(zone)
val end = event.end.toLocalDateTime(zone)
val startMin = start.time.toSecondOfDay() / 60
val endMin = (end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
end.time.toSecondOfDay() / 60
return WallClockSpan(start.date, startMin, maxOf(startMin, endMin))
/**
* [minutes] past [date]'s midnight, either way past it, as wall clock in [zone]
* (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless
* [keepOffsetOf] is itself in that hour's later one: an event there stays on it
* (#387). An instant outside the hour says nothing about which pass the grid
* slot means, so a drop never depends on the season the event came from.
*/
fun wallClockInstant(
date: LocalDate,
minutes: Int,
zone: TimeZone,
keepOffsetOf: Instant? = null,
): Instant {
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = minutes.mod(MINUTES_PER_DAY)
val kept = keepOffsetOf?.takeIf { it.inRepeatedHour(zone) }?.let(zone::offsetAt)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).resolveIn(zone, kept)
}
/**
@@ -1276,7 +1271,7 @@ private fun DragCopy(
Text(
text = label,
// As the block it lifted off sets its own time (#219).
style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()),
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = timeMaxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
@@ -1297,8 +1292,7 @@ fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
val target = if (selected) 1f else 0f
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
}
// The effect runs a frame late, and that frame would show the block unlifted.
return if (selected && snap) 1f else raise.value
return raise.value
}
/**
@@ -102,6 +102,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch
@@ -807,12 +808,13 @@ private fun formatWhen(
}
val endLdt = instance.end.toJavaLocalDateTime(zid)
val startTime = withRepeatedHourZone(timeShort.format(startLdt), instance.start, zone, locale)
val endTime = withRepeatedHourZone(timeShort.format(endLdt), instance.end, zone, locale)
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
dateFull.format(startLdt.toLocalDate()) to
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
dateFull.format(startLdt.toLocalDate()) to "$startTime – $endTime"
} else {
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
val start = "${dateMedium.format(startLdt.toLocalDate())} $startTime"
val end = "${dateMedium.format(endLdt.toLocalDate())} $endTime"
"$start – $end" to null
}
}
@@ -116,10 +116,13 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.isPinnedAwayFrom
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timesIn
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
@@ -162,6 +165,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
@@ -174,7 +178,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime
import kotlin.time.toJavaInstant
@@ -184,6 +187,7 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Full-screen event form: create (v1.2, from the FAB's anchor date) and edit
@@ -201,7 +205,6 @@ fun EventEditScreen(
onSaved: () -> Unit,
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialEndMinutes: Int? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
@@ -221,7 +224,7 @@ fun EventEditScreen(
else -> {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
viewModel.openNew(date, initialStartMinutes, initialEndMinutes)
viewModel.openNew(date, initialStartMinutes)
}
}
}
@@ -663,9 +666,14 @@ private fun EventEditContent(
},
) {
Spacer(Modifier.height(4.dp))
val formZone = remember(form.timezone) { form.resolvedZone(TimeZone.currentSystemDefault()) }
val startAt = remember(form.start, form.startOffset, formZone) { form.startInstant(formZone) }
val endAt = remember(form.end, form.endOffset, formZone) { form.endInstant(formZone) }
ScheduleRow(
label = stringResource(R.string.event_edit_starts),
dateTime = form.start,
instant = startAt,
zone = formZone,
isAllDay = form.isAllDay,
locale = locale,
onDateClick = { picker = PickerTarget.StartDate },
@@ -675,6 +683,8 @@ private fun EventEditContent(
ScheduleRow(
label = stringResource(R.string.event_edit_ends),
dateTime = form.end,
instant = endAt,
zone = formZone,
isAllDay = form.isAllDay,
locale = locale,
onDateClick = { picker = PickerTarget.EndDate },
@@ -693,20 +703,14 @@ private fun EventEditContent(
// The rows above edit a pinned event in *its own* zone, which is the
// time it was set at — but that says nothing about when it lands for
// whoever is reading it. Spell the local equivalent out rather than
// leaving the user to do the offset arithmetic. Absent (null) unless
// the event is pinned somewhere other than here.
// Keyed rather than recomputed: this sits in the same Column as the
// title field, so it would otherwise re-parse the zone on every
// keystroke.
val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) {
form.timesIn(TimeZone.currentSystemDefault())
}
localTimes?.let { (localStart, localEnd) ->
// leaving the user to do the offset arithmetic. Absent unless the
// event is pinned somewhere other than here.
if (form.isPinnedAwayFrom(TimeZone.currentSystemDefault())) {
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(
R.string.event_edit_timezone_local_time,
formatTimeRange(localStart, localEnd, locale),
formatTimeRange(startAt, endAt, TimeZone.currentSystemDefault(), locale),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -770,7 +774,7 @@ private fun EventEditContent(
timeZoneOptionOf(
id,
locale,
at = form.start.toInstant(zone).toJavaInstant(),
at = form.startInstant(zone).toJavaInstant(),
regionOf = ::icuTimeZoneRegion,
)
}
@@ -2252,18 +2256,21 @@ private fun EditCard(
}
/**
* "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
* time when both land on the same minute (an instant event shouldn't read as a
* range against itself). Dates are deliberately absent — the rows above carry
* them.
* "2:00 PM – 3:00 PM" in [zone], honouring the user's 12/24-hour setting. Collapses
* to one time for an instant event, which shouldn't read as a range against
* itself. Dates are deliberately absent — the rows above carry them.
*/
@Composable
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
val use24Hour = LocalUse24HourFormat.current
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
val from = timeFormat.format(start.time.toJavaLocalTime())
val to = timeFormat.format(end.time.toJavaLocalTime())
return if (from == to) from else "$from – $to"
fun format(at: Instant) = withRepeatedHourZone(
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
at,
zone,
locale,
)
return if (start == end) format(start) else "${format(start)} – ${format(end)}"
}
/**
@@ -2345,6 +2352,8 @@ private fun firstLineHeight(
private fun ScheduleRow(
label: String,
dateTime: LocalDateTime,
instant: Instant,
zone: TimeZone,
isAllDay: Boolean,
locale: Locale,
onDateClick: () -> Unit,
@@ -2387,7 +2396,7 @@ private fun ScheduleRow(
)
if (!isAllDay) {
Text(
text = timeFormat.format(dateTime.time.toJavaLocalTime()),
text = withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
@@ -19,6 +19,12 @@ import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.resolveIn
import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.withEnd
import de.jeanlucmakiola.calendula.domain.withTimes
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.isEventTarget
@@ -26,7 +32,6 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import de.jeanlucmakiola.calendula.ui.common.wallClockTime
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -50,6 +55,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
@@ -290,7 +296,11 @@ class EventEditViewModel @Inject constructor(
EventEditUiState(
form = resolved,
calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(),
problems = if (local.showProblems) {
resolved.problems(TimeZone.currentSystemDefault())
} else {
emptySet()
},
saveState = local.saveState,
visibleFields = visibleFields,
hiddenFields = (offerableFields - visibleFields).sorted(),
@@ -325,32 +335,31 @@ class EventEditViewModel @Inject constructor(
* in the day/week grid; without it the default is the next full hour (today)
* or 09:00 (any other day). The event opens an hour long and is stretched to
* the configured default by [applyDefaultDuration], like the reminders.
* A block drafted on the timeline (#153) passes [endMinutes] instead,
* counted from [date]'s midnight and past 1440 for a later day.
* No-op when a form is already open, so user input survives configuration
* changes; [reset] clears it when the screen closes.
*/
fun openNew(date: LocalDate, startMinutes: Int? = null, endMinutes: Int? = null) {
fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return
val zone = TimeZone.currentSystemDefault()
val now = Clock.System.now()
val start = when {
val startAt = when {
startMinutes != null ->
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone)
date == now.toLocalDateTime(zone).date -> {
// Today: the next full hour (may roll into tomorrow before midnight).
val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
}
else -> LocalDateTime(date, LocalTime(9, 0))
else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone)
}
val chosenEnd = endMinutes?.takeIf { startMinutes != null && it > startMinutes }
?.let { wallClockTime(date, it) }
val end = chosenEnd ?: (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
.toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
if (chosenEnd != null) _durationTouched.value = true
val endAt = startAt + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes
_form.value = EventForm(
calendarId = null,
start = startAt.toLocalDateTime(zone),
end = endAt.toLocalDateTime(zone),
startOffset = zone.offsetAt(startAt),
endOffset = zone.offsetAt(endAt),
)
applyDefaultReminder()
applyDefaultDuration()
}
@@ -460,11 +469,10 @@ class EventEditViewModel @Inject constructor(
if (_editTarget.value != null || _durationTouched.value) return@launch
if (_form.value?.isAllDay != false) return@launch
val duration = resolveDefaultEventDuration(global, overrides, targetId)
val zone = TimeZone.currentSystemDefault()
update { form ->
form.copy(
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
)
val zone = form.resolvedZone(TimeZone.currentSystemDefault())
val start = form.startInstant(zone)
form.withTimes(start, start + duration.minutes, zone)
}
}
}
@@ -669,12 +677,12 @@ class EventEditViewModel @Inject constructor(
// default duration, so a later calendar switch keeps their span (#54).
fun setEndDate(date: LocalDate) {
_durationTouched.value = true
update { it.copy(end = LocalDateTime(date, it.end.time)) }
update { it.withEnd(LocalDateTime(date, it.end.time)) }
}
fun setEndTime(time: LocalTime) {
_durationTouched.value = true
update { it.copy(end = LocalDateTime(it.end.date, time)) }
update { it.withEnd(LocalDateTime(it.end.date, time)) }
}
/**
@@ -687,7 +695,7 @@ class EventEditViewModel @Inject constructor(
val current = state.value ?: return
if (current.saveState == SaveUiState.Saving) return
val form = current.form
if (form.problems().isNotEmpty()) {
if (form.problems(TimeZone.currentSystemDefault()).isNotEmpty()) {
_showProblems.value = true
return
}
@@ -816,11 +824,13 @@ class EventEditViewModel @Inject constructor(
}
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
val zone = TimeZone.currentSystemDefault()
val newStart = transform(form.start)
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone)
form.copy(start = newStart, end = newEnd)
val zone = form.resolvedZone(TimeZone.currentSystemDefault())
val duration = form.endInstant(zone) - form.startInstant(zone)
// A picked time can't say which pass of a repeated hour it means; only
// an unchanged start keeps its own (see [withEnd]).
val picked = transform(form.start)
val newStart = picked.resolveIn(zone, form.startOffset.takeIf { picked == form.start })
form.withTimes(newStart, newStart + duration, zone)
}
private inline fun update(block: (EventForm) -> EventForm) {
@@ -52,6 +52,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.key
@@ -64,16 +65,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.toArgb
import de.jeanlucmakiola.calendula.ui.common.draftBlock
import de.jeanlucmakiola.calendula.ui.common.draftEvent
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
import de.jeanlucmakiola.calendula.ui.common.isDraft
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
import de.jeanlucmakiola.calendula.ui.common.wallClockSpan
import de.jeanlucmakiola.calendula.ui.common.PickUp
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
@@ -249,7 +240,6 @@ fun TimelineScreen(
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
modifier: Modifier = Modifier,
weekViewModel: WeekViewModel = hiltViewModel(),
multiDayViewModel: MultiDayViewModel = hiltViewModel(),
@@ -264,7 +254,6 @@ fun TimelineScreen(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
modifier = modifier,
)
}
@@ -311,6 +300,12 @@ internal val CalendarView.timelineKind: TimelineKind
else -> TimelineKind.Week
}
/**
* The zone the shown timeline is laid out in, so its blocks, drags and drops all
* read the same wall clock (#387).
*/
internal val LocalTimelineZone = staticCompositionLocalOf { TimeZone.currentSystemDefault() }
/** How long a switch waits for the view it goes to before resizing into it without its events. */
private const val MORPH_DATA_WAIT_MS = 150L
@@ -322,7 +317,6 @@ private fun PagedTimelineScreen(
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
modifier: Modifier,
) {
// The view on screen, which trails [view] while the columns resize into it.
@@ -355,7 +349,6 @@ private fun PagedTimelineScreen(
val grid = position.grid
val anchorPage = position.page
val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() }
val draftMinutes by key(viewModel) { viewModel.defaultEventDuration.collectAsStateWithLifecycle() }
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
@@ -449,7 +442,8 @@ private fun PagedTimelineScreen(
// Off the ticking clock rather than the loaded page, so it turns over at
// midnight; derived, so the per-minute tick doesn't recompose the screen.
val zone = remember { TimeZone.currentSystemDefault() }
// The timeline's own zone, so "today" agrees with the blocks and drops (#387).
val zone = viewModel.zone
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
@@ -490,7 +484,10 @@ private fun PagedTimelineScreen(
)
ScreenBody(modifier = modifier) { innerPadding ->
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
CompositionLocalProvider(
LocalDimCutoff provides dimCutoff,
LocalTimelineZone provides viewModel.zone,
) {
WeekContent(
state = state,
today = today,
@@ -504,8 +501,6 @@ private fun PagedTimelineScreen(
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
onCreateSpan = onCreateSpan,
draftMinutes = draftMinutes,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
@@ -528,8 +523,6 @@ private fun WeekContent(
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
draftMinutes: Int,
modifier: Modifier = Modifier,
) {
val fadeSpec = rememberCalendarFadeSpec()
@@ -544,39 +537,33 @@ private fun WeekContent(
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
val dragController = rememberTimelineDragController()
SideEffect { dragController.draftMinutes = draftMinutes }
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
val onDrop: (TimelineDrop) -> Unit = drop@{ drop ->
val start = drop.startInstant(zone)
val moved = drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start))
// A draft has nothing to write, so it stays selected where it landed (#153).
if (drop.event.isDraft) {
dragController.select(moved)
return@drop
}
val zone = LocalTimelineZone.current
val onDrop: (TimelineDrop) -> Unit = { drop ->
// A selected event stays selected where it landed (#141).
if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
dragController.select(moved, moved = true)
val start = drop.startInstant(zone)
dragController.select(
drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start)),
moved = true,
)
}
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(start),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
}
// A resize keeps its event selected, at its new length (#141).
val onResize: (TimelineResizeDrop) -> Unit = resize@{ drop ->
val resized = drop.event.copy(start = drop.start, end = drop.end)
if (drop.event.isDraft) {
dragController.select(resized)
return@resize
}
dragController.select(resized, moved = true)
val onResize: (TimelineResizeDrop) -> Unit = { drop ->
dragController.select(
drop.event.copy(start = drop.start, end = drop.end),
moved = true,
)
move?.move(
MoveRequest(
eventId = drop.event.eventId,
@@ -587,13 +574,6 @@ private fun WeekContent(
)
}
// A drafted block (#153) opens the new-event form at the span it shows.
val createDraft: (EventInstance) -> Unit = { draft ->
dragController.deselect()
val span = wallClockSpan(draft, zone)
onCreateSpan(span.day, span.startMin, span.endMin)
}
// Selection (#141) ends on back, a page or view change, or any tap.
val selection = dragController.selection
PublishSelection(
@@ -601,15 +581,10 @@ private fun WeekContent(
busy = dragController.isDragging,
compact = dragController.selectionCovered,
onDismiss = dragController::deselect,
onCreate = selection?.event?.takeIf { it.isDraft }?.let { draft -> { createDraft(draft) } },
)
BackHandler(enabled = selection != null) { dragController.deselect() }
LaunchedEffect(pagerState, dragController) {
snapshotFlow { pagerState.currentPage }.drop(1).collect {
// Deselecting would delete a draft mid-drag, so it stays until it lands.
val draftInFlight = dragController.isDragging && dragController.selection?.event?.isDraft == true
if (!draftInFlight) dragController.deselect()
}
snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() }
}
LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() }
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
@@ -623,14 +598,9 @@ private fun WeekContent(
delay(SELECTION_ORPHAN_GRACE_MILLIS)
dragController.deselect()
}
val requireWrite = rememberWriteGate()
val openEvent: (EventInstance) -> Unit = { event ->
if (event.isDraft) {
requireWrite { createDraft(event) }
} else {
dragController.deselect()
onEventClick(event)
}
dragController.deselect()
onEventClick(event)
}
Box(modifier = modifier) {
@@ -1410,16 +1380,10 @@ private fun DayColumnCard(
val hourPx = with(density) { hourHeight.toPx() }
val morphing = LocalViewMorph.current != null
val showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() }
// A block drafted on an empty slot (#153), drawn over the part of it on this day.
val draft by remember(dragController) {
derivedStateOf { dragController.selection?.event?.takeIf { it.isDraft } }
}
val draftPiece = remember(draft, date, zone) { draft?.let { draftBlock(it, date, zone) } }
val shown = remember(blocks, draftPiece) { if (draftPiece == null) blocks else blocks + draftPiece }
val zone = LocalTimelineZone.current
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(shown, dragController.settling) {
dragController.noteGrid(date, shown)
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
@@ -1429,7 +1393,6 @@ private fun DayColumnCard(
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, onDrop)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -1468,17 +1431,14 @@ private fun DayColumnCard(
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
}
// Inside the tap detector, which consumes every down, so only a
// block or handle on top can claim the press first.
.then(draftSurface),
},
) {
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
shown.forEach { block ->
blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val top = hourHeight * (block.startMin / 60f)
@@ -1489,7 +1449,6 @@ private fun DayColumnCard(
widthFraction = 1f / block.laneCount,
y = top,
height = height,
landing = dragController.ghosts(block),
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
@@ -1576,8 +1535,10 @@ private fun EventBlock(
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val zone = LocalTimelineZone.current
val labelEndMin = remember(block, date, zone) { block.labelEndMin(date, zone) }
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
minToHm(block.endMin, use24Hour, locale)
minToHm(labelEndMin, use24Hour, locale)
val density = LocalDensity.current
val metrics = rememberBlockTextMetrics(height)
// Only full-width (non-overlapping) blocks that are tall enough show the
@@ -1591,7 +1552,7 @@ private fun EventBlock(
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val shownTime = rememberBlockTimeRange(
minToHm(block.startMin, use24Hour, locale),
minToHm(block.endMin, use24Hour, locale),
minToHm(labelEndMin, use24Hour, locale),
textWidth,
)
// A short block drops the title rather than serving a horizontally sliced
@@ -1622,10 +1583,8 @@ private fun EventBlock(
val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draft = block.event.isDraft
val draggable = draft || eventDragAllowed(block.event)
val draggable = eventDragAllowed(block.event)
val selectable = eventSelectAllowed()
val selected = dragController.isSelected(block)
val haptics = LocalHapticFeedback.current
@@ -1644,6 +1603,7 @@ private fun EventBlock(
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
zone = zone,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
@@ -1667,12 +1627,7 @@ private fun EventBlock(
// Released in place, the block takes over the copy's lift without a dip.
val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted)
val ghost = when {
takesOverLift && !lifted -> 1f
// A draft leaves no ghost behind, and the copy it lands under is identical.
draft -> if (lifted) 0f else 1f
else -> ghostFade
}
val ghost = if (takesOverLift && !lifted) 1f else ghostFade
val raise = selectionLift(selected, snap = takesOverLift)
// Tells the action bar to fold when it would cover the bottom handle.
val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() }
@@ -1747,10 +1702,9 @@ private fun EventBlock(
}
}
val end = { dragController.finishResize(zone)?.let(onResize) }
// No bounce: an overshoot reads as the block's ends jumping.
val handleIn by animateFloatAsState(
targetValue = if (handlesShown) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "handles",
)
val handleAlpha = Modifier.graphicsLayer {
@@ -1790,62 +1744,6 @@ private fun EventBlock(
}
}
/**
* A long press on an empty stretch of [date]'s column (#153): drafts a block of
* the default length from the slot under the finger and lifts it, as a long
* press on an event does. Presses on a block or handle are left to them.
*/
@Composable
private fun rememberDraftSurface(
date: LocalDate,
edges: OutOfViewEdges,
hourPx: Float,
dragController: TimelineDragController,
onDrop: (TimelineDrop) -> Unit,
): Modifier {
val currentDrop by rememberUpdatedState(onDrop)
val title = stringResource(R.string.event_edit_new_title)
val color = MaterialTheme.colorScheme.primary.toArgb()
val zone = remember { TimeZone.currentSystemDefault() }
val currentEdges by rememberUpdatedState(edges)
// The draft this gesture lifted, selected again on release after the cancel.
val lifted = remember { arrayOfNulls<EventInstance>(1) }
val currentHourPx by rememberUpdatedState(hourPx)
return rememberDragSurface(
enabled = eventSelectAllowed(),
key = date,
skipClaimedDown = true,
// A press on a block or handle was consumed by it and never gets here.
onPickUp = { local, pointer, node, _ ->
val hour = currentHourPx
if (hour <= 0f || currentEdges.covers(local.y)) return@rememberDragSurface false
val startMin = draftStartMin(local.y / hour * 60f)
val event = draftEvent(
title = title,
color = color,
start = wallClockInstant(date, startMin, zone),
minutes = dragController.draftMinutes,
)
val block = draftBlock(event, date, zone) ?: return@rememberDragSurface false
lifted[0] = event
dragController.beginDraft(
block = block,
pointerInRoot = pointer,
blockInRoot = node + Offset(0f, startMin / 60f * hour),
)
true
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(currentDrop) },
// An abandoned pickup leaves no draft behind; a release selects it again below.
onCancel = {
dragController.cancel()
if (dragController.selection?.event?.isDraft == true) dragController.deselect()
},
onRelease = { lifted[0]?.let { dragController.select(it, fromLift = true) } },
)
}
private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
/** How far in from the block's side a resize handle's centre sits. */
@@ -35,6 +35,7 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
@@ -81,7 +82,8 @@ abstract class PagedTimelineViewModel(
initialRule: (LocalDate) -> PageGrid,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
/** The zone every page is laid out in; drags and drops resolve in it too (#387). */
val zone: TimeZone = TimeZone.currentSystemDefault()
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
@@ -91,14 +93,6 @@ abstract class PagedTimelineViewModel(
initialValue = false,
)
/** How long a block drafted on an empty slot starts out (#153). */
val defaultEventDuration: StateFlow<Int> = settingsPrefs.defaultEventDurationMinutes
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SettingsPrefs.DEFAULT_EVENT_DURATION,
)
private val todayDate: LocalDate
get() = currentDate()
@@ -339,6 +333,62 @@ internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRang
return from..to
}
/**
* This instant's wall-clock time as minutes past [day]'s midnight in [zone] —
* negative before that day, past 1440 after it. Elapsed time since midnight would
* put everything after a DST change an hour off on that day's grid (#387).
*/
internal fun Instant.minutesPast(day: LocalDate, zone: TimeZone): Int {
val local = toLocalDateTime(zone)
val days = (local.date.toEpochDays() - day.toEpochDays()).toInt()
return days * MINUTES_PER_DAY + local.time.toSecondOfDay() / 60
}
/** [instant]'s [minutesPast] clamped to [day]. */
internal fun wallClockMinutes(instant: Instant, day: LocalDate, zone: TimeZone): Int =
instant.minutesPast(day, zone).coerceIn(0, MINUTES_PER_DAY)
/**
* Where a run from [start] to [end] that begins at [startMin] ends on [day]'s
* wall clock. One ending in the fall-back hour's repeat can read as ending before
* it began: it starts in that hour's first pass, so it covers the hour's wall
* clock from its start to where the hour ends, and is drawn to there.
*/
internal fun wallClockEndMinutes(
startMin: Int,
start: Instant,
end: Instant,
day: LocalDate,
zone: TimeZone,
): Int {
val wall = end.minutesPast(day, zone)
if (wall > startMin || end <= start) return maxOf(wall, startMin)
return repeatedHourEndMinutes(end, day, zone) ?: (startMin + (end - start).inWholeMinutes.toInt())
}
/**
* Where the repeated hour [end] falls in ends, as minutes past [day]'s midnight:
* the wall clock the zone fell back from (03:00 in Berlin). Null when the last
* change before [end] was no fall-back.
*/
private fun repeatedHourEndMinutes(end: Instant, day: LocalDate, zone: TimeZone): Int? {
// Just past [end], so an end on the transition itself still finds it.
val after = java.time.Instant.ofEpochMilli(end.toEpochMilliseconds() + 1)
val transition = zone.toJavaZoneId().rules.previousTransition(after)
?.takeIf { it.isOverlap } ?: return null
val local = transition.dateTimeBefore
val days = (local.toLocalDate().toEpochDay() - day.toEpochDays()).toInt()
return days * MINUTES_PER_DAY + local.toLocalTime().toSecondOfDay() / 60
}
/**
* The end a block's time label names on [day]: its event's own wall-clock end,
* which for one ending in the fall-back hour's repeat is earlier than the
* [TimedBlock.endMin] it is drawn to (#387).
*/
internal fun TimedBlock.labelEndMin(day: LocalDate, zone: TimeZone): Int =
minOf(endMin, wallClockMinutes(event.end, day, zone))
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
if (isAllDay) {
@@ -392,14 +442,14 @@ internal fun layoutDay(
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
val m = wallClockMinutes(ev.start, day, zone)
Raw(ev, m, m)
} else {
val s = maxOf(ev.start, dayStart)
val e = minOf(ev.end, dayEnd)
if (e <= s) return@mapNotNull null
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
val startMin = wallClockMinutes(s, day, zone)
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
Raw(ev, startMin, endMin)
}
}
-2
View File
@@ -102,8 +102,6 @@
<string name="event_selection_actions">Event actions</string>
<string name="event_selection_hide">Hide event actions</string>
<string name="event_selection_close">Deselect event</string>
<string name="event_draft_create">Create event</string>
<string name="event_draft_discard">Discard new event</string>
<string name="event_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>
@@ -9,8 +9,10 @@ import de.jeanlucmakiola.calendula.domain.realignRecurrence
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.UtcOffset
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Instant
class EventWriteMapperTest {
@@ -1387,4 +1389,37 @@ class EventWriteMapperTest {
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `a time in the repeated fall-back hour is written at its own offset`() {
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 0))
val times = form(start = repeated, end = repeated)
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 1))
.toWriteTimes(berlin)
assertThat(times.dtStartMillis).isEqualTo(Instant.parse("2026-10-25T00:00:00Z").toEpochMilliseconds())
assertThat(times.dtEndMillis).isEqualTo(Instant.parse("2026-10-25T01:00:00Z").toEpochMilliseconds())
}
@Test
fun `a move between the two repeated fall-back hours writes the new times`() {
// 02:30 CEST to 02:30 CET: the same wall clock, an hour later.
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val original = form(start = repeated, end = repeated)
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 2))
val updated = original.copy(startOffset = UtcOffset(hours = 1), endOffset = UtcOffset(hours = 1))
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(Instant.parse("2026-10-25T01:30:00Z").toEpochMilliseconds())
}
@Test
fun `an untouched form in the repeated fall-back hour writes no times`() {
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val original = form(start = repeated, end = repeated)
.copy(startOffset = UtcOffset(hours = 2), endOffset = UtcOffset(hours = 1))
assertThat(update(original, original.copy(title = "Renamed")))
.doesNotContainKey(CalendarContract.Events.DTSTART)
}
}
@@ -19,25 +19,25 @@ class EventFormTest {
@Test
fun `valid timed form has no problems`() {
assertThat(form().problems()).isEmpty()
assertThat(form().problems(berlin)).isEmpty()
}
@Test
fun `missing calendar is a problem`() {
assertThat(form(calendarId = null).problems())
assertThat(form(calendarId = null).problems(berlin))
.containsExactly(EventFormProblem.NoCalendar)
}
@Test
fun `timed end before start is a problem`() {
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)))
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
assertThat(bad.problems(berlin)).containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `zero-length timed event is allowed`() {
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(instant.problems()).isEmpty()
assertThat(instant.problems(berlin)).isEmpty()
}
@Test
@@ -47,7 +47,7 @@ class EventFormTest {
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
)
assertThat(allDay.problems()).isEmpty()
assertThat(allDay.problems(berlin)).isEmpty()
}
@Test
@@ -57,7 +57,7 @@ class EventFormTest {
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)),
)
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
assertThat(bad.problems(berlin)).containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
@@ -66,7 +66,7 @@ class EventFormTest {
calendarId = null,
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
)
assertThat(bad.problems()).containsExactly(
assertThat(bad.problems(berlin)).containsExactly(
EventFormProblem.NoCalendar,
EventFormProblem.EndBeforeStart,
)
@@ -76,28 +76,28 @@ class EventFormTest {
fun `recurrence until before the first day is a problem`() {
// Days before the start, so it parses to an earlier date in any zone.
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
assertThat(bad.problems())
assertThat(bad.problems(berlin))
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
@Test
fun `recurrence until on or after the first day is fine`() {
// Date-only UNTIL parses zone-independently.
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems(berlin)).isEmpty()
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems(berlin)).isEmpty()
}
@Test
fun `complex rrules are not validated against the start`() {
// The picker can't have produced this ("second Monday" ordinal BYDAY);
// it is preserved verbatim and never flagged.
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems(berlin)).isEmpty()
}
@Test
fun `weekly byday rules are validated against the start`() {
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
assertThat(bad.problems())
assertThat(bad.problems(berlin))
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
@@ -210,64 +210,23 @@ class EventFormTest {
}
@Test
fun `timesIn converts a pinned event into the target zone`() {
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
timezone = "America/New_York",
)
val (start, end) = form.timesIn(berlin)!!
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
}
@Test
fun `timesIn crosses the date line when the offset pushes past midnight`() {
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)),
timezone = "America/New_York",
)
val (start, _) = form.timesIn(berlin)!!
// 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move
// with it, not just the clock.
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
}
@Test
fun `timesIn tracks each zone's own DST rather than a fixed offset`() {
// In January both are on standard time: 08:00 EST is 14:00 CET — the
// same six hours as July, but only because both shifted. Late March,
// when the US has sprung forward and Europe hasn't, the gap is five.
val march = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)),
timezone = "America/New_York",
)
assertThat(march.timesIn(berlin)!!.first)
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
}
@Test
fun `timesIn returns null when there is nothing to disambiguate`() {
fun `isPinnedAwayFrom is false when there is nothing to disambiguate`() {
val base = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
)
// Unpinned: the form's times already are the local times.
assertThat(base.timesIn(berlin)).isNull()
assertThat(base.isPinnedAwayFrom(berlin)).isFalse()
// Pinned to the target itself: same thing.
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
assertThat(base.copy(timezone = "Europe/Berlin").isPinnedAwayFrom(berlin)).isFalse()
// All-day: date-anchored, so there's no zone conversion to show.
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
.isNull()
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").isPinnedAwayFrom(berlin))
.isFalse()
// Unparseable: can't convert, mustn't throw.
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
assertThat(base.copy(timezone = "Mars/Olympus_Mons").isPinnedAwayFrom(berlin)).isFalse()
// Pinned elsewhere: worth spelling out.
assertThat(base.copy(timezone = "America/New_York").isPinnedAwayFrom(berlin)).isTrue()
}
@Test
@@ -425,4 +384,43 @@ class EventFormTest {
assertThat(prefilled.color).isNull()
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color)
}
@Test
fun `toEditForm keeps which repeated fall-back hour each end is in`() {
val start = Instant.parse("2026-10-25T00:00:00Z")
val end = Instant.parse("2026-10-25T01:00:00Z")
val prefilled = detail().toEditForm(start.toEpochMilliseconds(), end.toEpochMilliseconds(), berlin)
assertThat(prefilled.start).isEqualTo(prefilled.end)
assertThat(prefilled.startInstant(berlin)).isEqualTo(start)
assertThat(prefilled.endInstant(berlin)).isEqualTo(end)
}
@Test
fun `a span ending in the repeated fall-back hour is valid`() {
// 02:45 CEST to 02:15 CET: half an hour, though the clock reads backwards.
val form = form().withTimes(
Instant.parse("2026-10-25T00:45:00Z"),
Instant.parse("2026-10-25T01:15:00Z"),
berlin,
)
assertThat(form.end).isLessThan(form.start)
assertThat(form.problems(berlin)).isEmpty()
// Read as the same pass at both ends, it really does end first.
assertThat(form.copy(startOffset = null, endOffset = null).problems(berlin))
.containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `an end picked by hand takes the earlier repeated hour`() {
val form = form().withTimes(
Instant.parse("2026-10-25T00:45:00Z"),
Instant.parse("2026-10-25T01:15:00Z"),
berlin,
)
// Picking the end it already has keeps its pass.
assertThat(form.withEnd(form.end)).isEqualTo(form)
val picked = form.withEnd(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 50)))
assertThat(picked.endOffset).isNull()
assertThat(picked.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
}
}
@@ -5,8 +5,10 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class EventShiftTest {
@@ -88,8 +90,10 @@ class EventShiftTest {
@Test
fun `a zero-distance drop leaves the form untouched`() {
val original = form()
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original)
// As read from the provider, offsets and all.
val start = form().start.toInstant(berlin)
val original = form().withTimes(start, form().end.toInstant(berlin), berlin)
assertThat(original.shiftedTo(start, berlin)).isEqualTo(original)
}
@Test
@@ -197,4 +201,35 @@ class EventShiftTest {
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
}
// 02:00 CEST to 02:00 CET on 2026-10-25: one real hour, one wall-clock value.
private val repeatStart = Instant.parse("2026-10-25T00:00:00Z")
private val repeatEnd = Instant.parse("2026-10-25T01:00:00Z")
@Test
fun `moving an event in the repeated fall-back hour keeps its length`() {
val original = form().withTimes(repeatStart, repeatEnd, berlin)
val moved = original.shiftedTo(Instant.parse("2026-10-25T00:30:00Z"), berlin)
assertThat(moved.startInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
assertThat(moved.endInstant(berlin)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
}
@Test
fun `a move onto the second repeated hour stays there`() {
val target = Instant.parse("2026-10-25T01:30:00Z")
val moved = form().withTimes(repeatStart, repeatEnd, berlin).shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30)))
assertThat(moved.startInstant(berlin)).isEqualTo(target)
}
@Test
fun `resolveIn takes the preferred offset only where the hour repeats`() {
val repeated = LocalDateTime(LocalDate(2026, 10, 25), LocalTime(2, 30))
val cet = UtcOffset(hours = 1)
assertThat(repeated.resolveIn(berlin, cet)).isEqualTo(Instant.parse("2026-10-25T01:30:00Z"))
assertThat(repeated.resolveIn(berlin, null)).isEqualTo(Instant.parse("2026-10-25T00:30:00Z"))
// Not one of 14:00's offsets on a summer day: ignored.
val summer = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(14, 0))
assertThat(summer.resolveIn(berlin, cet)).isEqualTo(summer.toInstant(berlin))
}
}
@@ -1,68 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
/** A block drafted on an empty timeline slot (#153). */
class DraftBlockTest {
private val zone = TimeZone.UTC
private val day = LocalDate(2026, 10, 9)
private fun draft(startMin: Int, minutes: Int) =
draftEvent("New event", 0, wallClockInstant(day, startMin, zone), minutes)
@Test
fun `a press snaps down to the slot it is in`() {
assertThat(draftStartMin(9 * 60 + 14f)).isEqualTo(9 * 60)
assertThat(draftStartMin(9 * 60 + 15f)).isEqualTo(9 * 60 + 15)
}
@Test
fun `a press at the very bottom still leaves a slot before midnight`() {
assertThat(draftStartMin(24 * 60f)).isEqualTo(24 * 60 - DRAG_SNAP_MINUTES)
}
@Test
fun `the draft is told apart from real events`() {
assertThat(draft(9 * 60, 60).isDraft).isTrue()
}
@Test
fun `a draft within its day is one block the column wide`() {
val block = draftBlock(draft(9 * 60, 60), day, zone)!!
assertThat(block.startMin).isEqualTo(9 * 60)
assertThat(block.endMin).isEqualTo(10 * 60)
assertThat(block.lane to block.laneCount).isEqualTo(0 to 1)
}
@Test
fun `a draft past midnight is cut there and carries on the next day`() {
val event = draft(23 * 60, 120)
val first = draftBlock(event, day, zone)!!
val second = draftBlock(event, day.plusDays(1), zone)!!
assertThat(first.startMin to first.endMin).isEqualTo(23 * 60 to 24 * 60)
assertThat(second.startMin to second.endMin).isEqualTo(0 to 60)
}
@Test
fun `a draft ending at midnight leaves the next day alone`() {
assertThat(draftBlock(draft(23 * 60, 60), day.plusDays(1), zone)).isNull()
}
@Test
fun `a draft misses the day before it`() {
assertThat(draftBlock(draft(9 * 60, 60), day.plusDays(-1), zone)).isNull()
}
@Test
fun `a draft past midnight opens the form with its end on the next day`() {
val span = wallClockSpan(draft(23 * 60, 120), zone)
assertThat(span).isEqualTo(WallClockSpan(day, 23 * 60, 25 * 60))
assertThat(wallClockTime(span.day, span.endMin)).isEqualTo(wallClockTime(day.plusDays(1), 60))
}
private fun LocalDate.plusDays(days: Int): LocalDate = LocalDate.fromEpochDays(toEpochDays() + days)
}
@@ -0,0 +1,30 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import java.util.Locale
import kotlin.time.Instant
/** The zone named after a time only where the fall-back hour repeats it (#387). */
class RepeatedHourZoneTest {
private val brussels = TimeZone.of("Europe/Brussels")
@Test
fun `both 02 30s of the fall-back day are told apart`() {
val first = withRepeatedHourZone("2:30 AM", Instant.parse("2026-10-25T00:30:00Z"), brussels, Locale.UK)
val second = withRepeatedHourZone("2:30 AM", Instant.parse("2026-10-25T01:30:00Z"), brussels, Locale.UK)
assertThat(first).startsWith("2:30 AM ")
assertThat(second).startsWith("2:30 AM ")
assertThat(first).isNotEqualTo(second)
}
@Test
fun `an unambiguous time stays bare`() {
assertThat(withRepeatedHourZone("2:45 PM", Instant.parse("2026-10-25T13:45:00Z"), brussels, Locale.UK))
.isEqualTo("2:45 PM")
assertThat(withRepeatedHourZone("3:30 AM", Instant.parse("2026-10-25T02:30:00Z"), brussels, Locale.UK))
.isEqualTo("3:30 AM")
}
}
@@ -4,12 +4,15 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import de.jeanlucmakiola.calendula.ui.week.minutesPast
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/**
@@ -153,4 +156,88 @@ class TimelineDropTest {
val block = layoutDay(listOf(plain), sat, zone).single()
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
private val brussels = TimeZone.of("Europe/Brussels")
private val fallBack = LocalDate(2026, 10, 25)
@Test
fun `a block on the fall-back day carries no clip offset`() {
// #387: 13:45Z is 14:45 CET, drawn and picked up at 14:45.
val event = overnight.copy(
start = Instant.parse("2026-10-25T13:45:00Z"),
end = Instant.parse("2026-10-25T15:45:00Z"),
)
val block = layoutDay(listOf(event), fallBack, brussels).single()
assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0)
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0)
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
}
@Test
fun `a tail on the fall-back day is offset by wall-clock time`() {
val event = overnight.copy(
start = LocalDate(2026, 10, 24).atTime(22, 0).toInstant(brussels),
end = fallBack.atTime(6, 0).toInstant(brussels),
)
val tail = layoutDay(listOf(event), fallBack, brussels).single()
val offset = tail.clipOffsetMinutes(fallBack, brussels)
assertThat(offset).isEqualTo(2 * 60)
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset)
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
}
@Test
fun `wall-clock span follows the clock change`() {
// 01:00 to 04:00 on the fall-back day is four real hours.
assertThat(wallClockSpanMinutes(fallBack, 60, 4.hours, brussels)).isEqualTo(3 * 60)
// 01:00 to 04:00 on the spring-forward day is two.
assertThat(wallClockSpanMinutes(LocalDate(2026, 3, 29), 60, 2.hours, brussels)).isEqualTo(3 * 60)
assertThat(wallClockSpanMinutes(sat, 20 * 60, 12.hours, zone)).isEqualTo(12 * 60)
}
@Test
fun `a drop in the repeated fall-back hour keeps the event's offset`() {
// 02:30 CET, the second 02:30 of the day, dragged 15 minutes later.
val event = overnight.copy(
start = Instant.parse("2026-10-25T01:30:00Z"),
end = Instant.parse("2026-10-25T02:30:00Z"),
)
val block = layoutDay(listOf(event), fallBack, brussels).single()
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0)
assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
}
@Test
fun `an ambiguous time without an offset to keep takes the earlier one`() {
assertThat(wallClockInstant(fallBack, 2 * 60 + 45, brussels))
.isEqualTo(Instant.parse("2026-10-25T00:45:00Z"))
}
@Test
fun `a start in the spring-forward gap resolves an hour on`() {
val springForward = LocalDate(2026, 3, 29)
val landed = wallClockInstant(springForward, 2 * 60 + 30, brussels, keepOffsetOf = overnight.start)
assertThat(landed.minutesPast(springForward, brussels)).isEqualTo(3 * 60 + 30)
assertThat(wallClockSpanMinutes(springForward, 3 * 60 + 30, 1.hours, brussels)).isEqualTo(60)
}
@Test
fun `a span ending in the repeated fall-back hour reaches that hour's end`() {
// 02:00 CEST plus one hour is 02:00 CET; 02:30 CEST plus one is 02:30 CET.
assertThat(wallClockSpanMinutes(fallBack, 2 * 60, 1.hours, brussels)).isEqualTo(60)
assertThat(wallClockSpanMinutes(fallBack, 2 * 60 + 30, 1.hours, brussels)).isEqualTo(30)
}
@Test
fun `a drop onto the repeated fall-back hour doesn't depend on the event's season`() {
val slot = 2 * 60 + 30
val first = Instant.parse("2026-10-25T00:30:00Z")
val july = Instant.parse("2026-07-01T10:00:00Z")
val november = Instant.parse("2026-11-10T10:00:00Z")
assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = july)).isEqualTo(first)
assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = november)).isEqualTo(first)
// Later the same day, after the clocks went back: still no say.
val evening = Instant.parse("2026-10-25T18:00:00Z")
assertThat(wallClockInstant(fallBack, slot, brussels, keepOffsetOf = evening)).isEqualTo(first)
}
}
@@ -190,5 +190,108 @@ class WeekLayoutTest {
assertThat(spans.all { it.lane == 0 }).isTrue()
}
// Europe/Brussels falls back on 2026-10-25 (25 h) and springs forward on 2026-03-29 (23 h).
private val brussels = TimeZone.of("Europe/Brussels")
private fun brusselsEvent(date: LocalDate, from: Pair<Int, Int>, to: Pair<Int, Int>) = event(
date.atTime(from.first, from.second).toInstant(brussels),
date.atTime(to.first, to.second).toInstant(brussels),
)
@Test
fun `timed block sits at its wall-clock time on the fall-back day`() {
val day = LocalDate(2026, 10, 25)
// The event from #387: 13:45Z is 14:45 CET.
val ev = event(Instant.parse("2026-10-25T13:45:00Z"), Instant.parse("2026-10-25T15:45:00Z"))
val b = layoutDay(listOf(ev), day, brussels).single()
assertThat(b.startMin).isEqualTo(14 * 60 + 45)
assertThat(b.endMin).isEqualTo(16 * 60 + 45)
}
@Test
fun `timed block sits at its wall-clock time on the spring-forward day`() {
val day = LocalDate(2026, 3, 29)
val b = layoutDay(listOf(brusselsEvent(day, 14 to 45, 16 to 45)), day, brussels).single()
assertThat(b.startMin).isEqualTo(14 * 60 + 45)
assertThat(b.endMin).isEqualTo(16 * 60 + 45)
}
@Test
fun `late block on the fall-back day is not cut short`() {
val day = LocalDate(2026, 10, 25)
val b = layoutDay(listOf(brusselsEvent(day, 22 to 30, 23 to 59)), day, brussels).single()
assertThat(b.startMin).isEqualTo(22 * 60 + 30)
assertThat(b.endMin).isEqualTo(23 * 60 + 59)
}
@Test
fun `block running past midnight on the fall-back day reaches the day's end`() {
val day = LocalDate(2026, 10, 25)
val ev = event(
day.atTime(23, 0).toInstant(brussels),
day.plusDays(1).atTime(1, 0).toInstant(brussels),
)
val b = layoutDay(listOf(ev), day, brussels).single()
assertThat(b.startMin).isEqualTo(23 * 60)
assertThat(b.endMin).isEqualTo(MINUTES_PER_DAY)
}
@Test
fun `instant event on the fall-back day sits at its wall-clock time`() {
val day = LocalDate(2026, 10, 25)
val at = day.atTime(9, 0).toInstant(brussels)
val b = layoutDay(listOf(event(at, at)), day, brussels).single()
assertThat(b.startMin).isEqualTo(9 * 60)
assertThat(b.endMin).isEqualTo(9 * 60)
}
@Test
fun `block ending in the repeated fall-back hour is drawn to that hour's end`() {
val day = LocalDate(2026, 10, 25)
// 02:00 CEST to 02:00 CET: one real hour, the same wall-clock time at both ends.
val ev = event(Instant.parse("2026-10-25T00:00:00Z"), Instant.parse("2026-10-25T01:00:00Z"))
val b = layoutDay(listOf(ev), day, brussels).single()
assertThat(b.startMin).isEqualTo(2 * 60)
assertThat(b.endMin).isEqualTo(3 * 60)
}
@Test
fun `block ending in the repeated fall-back hour leaves the next hour free`() {
val day = LocalDate(2026, 10, 25)
// 02:15 CEST to 02:15 CET, then 03:00 CET: an hour apart in real time.
val repeat = event(Instant.parse("2026-10-25T00:15:00Z"), Instant.parse("2026-10-25T01:15:00Z"))
val next = event(Instant.parse("2026-10-25T02:00:00Z"), Instant.parse("2026-10-25T03:00:00Z"))
val blocks = layoutDay(listOf(repeat, next), day, brussels)
assertThat(blocks.map { it.startMin to it.endMin })
.containsExactly(2 * 60 + 15 to 3 * 60, 3 * 60 to 4 * 60)
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
}
@Test
fun `minutesPast reads wall clock either side of the day`() {
val day = LocalDate(2026, 10, 25)
assertThat(Instant.parse("2026-10-24T21:00:00Z").minutesPast(day, brussels)).isEqualTo(-60)
assertThat(Instant.parse("2026-10-25T13:45:00Z").minutesPast(day, brussels)).isEqualTo(14 * 60 + 45)
assertThat(Instant.parse("2026-10-25T23:30:00Z").minutesPast(day, brussels))
.isEqualTo(MINUTES_PER_DAY + 30)
}
@Test
fun `a block ending in the repeated fall-back hour is labelled with its own end`() {
val day = LocalDate(2026, 10, 25)
// 02:30 CEST to 02:30 CET: drawn to the repeated hour's end, but it ends at 02:30.
val ev = event(Instant.parse("2026-10-25T00:30:00Z"), Instant.parse("2026-10-25T01:30:00Z"))
val b = layoutDay(listOf(ev), day, brussels).single()
assertThat(b.endMin).isEqualTo(3 * 60)
assertThat(b.labelEndMin(day, brussels)).isEqualTo(2 * 60 + 30)
}
@Test
fun `an ordinary block is labelled with its drawn end`() {
val day = LocalDate(2026, 10, 25)
val b = layoutDay(listOf(brusselsEvent(day, 14 to 45, 16 to 45)), day, brussels).single()
assertThat(b.labelEndMin(day, brussels)).isEqualTo(b.endMin)
}
private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
}