Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cb2f62627 | ||
|
|
427e87c6e6 |
@@ -15,7 +15,7 @@ jobs:
|
||||
detect:
|
||||
# Gitea only; see the same guard in release.yaml.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
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: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -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: docker
|
||||
runs-on: light
|
||||
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: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -1859,3 +1860,4 @@ 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
|
||||
|
||||
@@ -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 = 2230002
|
||||
versionName = "2.23.0-beta.2"
|
||||
versionCode = 2230003
|
||||
versionName = "2.23.0-beta.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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
|
||||
@@ -50,8 +48,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
} else {
|
||||
val writeZone = writeZone(zone)
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.resolveIn(writeZone.toKotlinTimeZone(), startOffset).toEpochMilliseconds(),
|
||||
dtEndMillis = end.resolveIn(writeZone.toKotlinTimeZone(), endOffset).toEpochMilliseconds(),
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
@@ -183,14 +181,12 @@ 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. So does a switch between the two passes of
|
||||
// the fall-back hour, which only the resolved instants tell apart (#387).
|
||||
// DTSTART has to move with it.
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule ||
|
||||
updated.timezone != original.timezone ||
|
||||
updated.toWriteTimes(zone) != original.toWriteTimes(zone)
|
||||
updated.timezone != original.timezone
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
|
||||
@@ -4,8 +4,7 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.UtcOffset
|
||||
import kotlinx.datetime.offsetAt
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -37,13 +36,6 @@ 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. */
|
||||
@@ -153,15 +145,16 @@ 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 = begin.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
|
||||
.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 {
|
||||
begin.toLocalDateTime(formZone) to finish.toLocalDateTime(formZone)
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -170,8 +163,6 @@ 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(),
|
||||
@@ -230,18 +221,20 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
)
|
||||
|
||||
/**
|
||||
* 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'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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,19 +254,9 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||
val endsTooEarly = if (isAllDay) {
|
||||
end.date < start.date
|
||||
} else {
|
||||
val zone = resolvedZone(deviceZone)
|
||||
endInstant(zone) < startInstant(zone)
|
||||
}
|
||||
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
||||
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,14 +3,10 @@ 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
|
||||
@@ -19,43 +15,6 @@ import kotlin.time.toJavaInstant
|
||||
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
|
||||
@@ -67,8 +26,11 @@ fun EventForm.withEnd(end: LocalDateTime): EventForm =
|
||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = endInstant(zone) - startInstant(zone)
|
||||
return withTimes(newStart, newStart + span, zone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(
|
||||
start = newStart.toLocalDateTime(zone),
|
||||
end = (newStart + span).toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,13 +48,16 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
)
|
||||
}
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = endInstant(zone) - startInstant(zone)
|
||||
val startAt = newStart.resolveIn(zone, startOffset)
|
||||
return withTimes(startAt, startAt + span, zone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(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
|
||||
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
|
||||
val zone = resolvedZone(deviceZone)
|
||||
return copy(
|
||||
start = newStart.toLocalDateTime(zone),
|
||||
end = newEnd.toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,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.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
@@ -35,15 +35,7 @@ fun buildInsertEventForm(
|
||||
zone: TimeZone,
|
||||
now: Instant,
|
||||
): EventForm {
|
||||
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) {
|
||||
val (start, end) = if (isAllDay) {
|
||||
// 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).
|
||||
@@ -56,7 +48,14 @@ fun buildInsertEventForm(
|
||||
?: startDate
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
timed.first.toLocalDateTime(zone) to timed.second.toLocalDateTime(zone)
|
||||
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
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
@@ -64,8 +63,6 @@ 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:"
|
||||
@@ -74,7 +71,8 @@ fun buildInsertEventForm(
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextFullHour(now: Instant): Instant {
|
||||
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
||||
val hourMillis = 3_600_000L
|
||||
return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
|
||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||
}
|
||||
|
||||
@@ -157,18 +157,12 @@ 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 if (abbrev.startsWith("GMT")) formatGmtOffset(option.offsetMinutes) else abbrev
|
||||
return when {
|
||||
abbrev == "UTC" -> "UTC"
|
||||
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,6 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.offsetAt
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
/**
|
||||
@@ -35,8 +34,6 @@ 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,12 +239,18 @@ fun CalendarHost(
|
||||
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
|
||||
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
|
||||
// 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 ->
|
||||
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
|
||||
@@ -380,6 +386,8 @@ fun CalendarHost(
|
||||
createDateIso = iso
|
||||
heldCreateMinutes = null
|
||||
createStartMinutes = null
|
||||
heldCreateEndMinutes = null
|
||||
createEndMinutes = null
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
null -> {}
|
||||
@@ -583,6 +591,7 @@ fun CalendarHost(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onCreateSpan = onCreateSpan,
|
||||
)
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
@@ -671,6 +680,7 @@ fun CalendarHost(
|
||||
EventEditScreen(
|
||||
initialDateIso = iso,
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
|
||||
@@ -278,12 +278,23 @@ 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): BlockPlacement {
|
||||
fun animatedBlockPlacement(
|
||||
xFraction: Float,
|
||||
widthFraction: Float,
|
||||
y: Dp,
|
||||
height: Dp,
|
||||
landing: Boolean = false,
|
||||
): BlockPlacement {
|
||||
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = when {
|
||||
still -> snap()
|
||||
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
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,9 +69,15 @@ 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)
|
||||
class EventSelection(
|
||||
val event: EventInstance,
|
||||
val dismiss: () -> Unit,
|
||||
val create: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||
@Stable
|
||||
@@ -114,10 +120,26 @@ 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) {
|
||||
fun PublishSelection(
|
||||
event: EventInstance?,
|
||||
busy: Boolean,
|
||||
compact: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (() -> Unit)? = null,
|
||||
) {
|
||||
val slot = LocalChromeSlot.current ?: return
|
||||
val currentDismiss by rememberUpdatedState(onDismiss)
|
||||
val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
|
||||
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 host = slot.host
|
||||
val current = slot.current
|
||||
DisposableEffect(host, selection, current) {
|
||||
|
||||
@@ -73,7 +73,8 @@ 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.
|
||||
* whether anything is there; false abandons the gesture. [skipClaimedDown] leaves
|
||||
* a press a descendant already consumed alone.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDragSurface(
|
||||
@@ -83,6 +84,8 @@ fun rememberDragSurface(
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRelease: (() -> Unit)? = null,
|
||||
skipClaimedDown: Boolean = false,
|
||||
): Modifier = rememberPickUpSurface(
|
||||
enabled = enabled,
|
||||
key = key,
|
||||
@@ -93,7 +96,8 @@ fun rememberDragSurface(
|
||||
onMove = onMove,
|
||||
onDrop = onDrop,
|
||||
onCancel = onCancel,
|
||||
onRelease = null,
|
||||
onRelease = onRelease,
|
||||
skipClaimedDown = skipClaimedDown,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -106,6 +110,7 @@ private fun rememberPickUpSurface(
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRelease: (() -> Unit)?,
|
||||
skipClaimedDown: Boolean = false,
|
||||
): Modifier {
|
||||
if (!enabled) return Modifier
|
||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||
@@ -125,6 +130,8 @@ 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,6 +5,8 @@ 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
|
||||
@@ -52,6 +54,26 @@ 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,6 +47,23 @@ 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,15 +195,20 @@ 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 = tappedEdge(
|
||||
events = events,
|
||||
y = y,
|
||||
viewportTop = scrollState.value.toFloat(),
|
||||
viewportPx = scrollState.viewportSize,
|
||||
reachPx = reachPx,
|
||||
) ?: return false
|
||||
val edge = edgeAt(y) ?: return false
|
||||
reveal(edge)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ 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
|
||||
@@ -331,7 +329,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(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
@@ -497,7 +495,8 @@ 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.endInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds()
|
||||
return form.end.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
||||
.toEpochMilliseconds()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,7 +508,8 @@ class RescheduleViewModel @Inject constructor(
|
||||
return if (form.isAllDay) {
|
||||
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
} else {
|
||||
form.startInstant(form.resolvedZone(TimeZone.currentSystemDefault())).toEpochMilliseconds()
|
||||
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
||||
.toEpochMilliseconds()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ fun SelectionTopBar(selection: EventSelection) {
|
||||
IconButton(onClick = selection.dismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_selection_close),
|
||||
contentDescription = stringResource(
|
||||
if (selection.create != null) R.string.event_draft_discard else R.string.event_selection_close,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -74,15 +68,3 @@ 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,13 +44,10 @@ 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
|
||||
@@ -58,7 +55,6 @@ 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
|
||||
@@ -66,7 +62,7 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||
@@ -275,6 +271,41 @@ 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 }
|
||||
|
||||
@@ -397,6 +428,9 @@ 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
|
||||
@@ -407,6 +441,7 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
fun deselect() {
|
||||
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
|
||||
selection = null
|
||||
selectionCovered = false
|
||||
}
|
||||
@@ -512,8 +547,6 @@ 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
|
||||
@@ -547,7 +580,6 @@ class TimelineDragController {
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
clipOffsetMin: Int,
|
||||
zone: TimeZone,
|
||||
titleLines: Int,
|
||||
pointerInRoot: Offset,
|
||||
blockInRoot: Offset,
|
||||
@@ -556,8 +588,6 @@ 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
|
||||
@@ -571,6 +601,16 @@ 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
|
||||
@@ -584,8 +624,6 @@ class TimelineDragController {
|
||||
resizeSlot = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
dragDuration = Duration.ZERO
|
||||
titleLines = 0
|
||||
isDragging = false
|
||||
pointerMoved = false
|
||||
@@ -653,6 +691,8 @@ 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
|
||||
@@ -676,13 +716,12 @@ class TimelineDragController {
|
||||
titleLines: Int,
|
||||
pointerInRoot: Offset,
|
||||
) {
|
||||
val start = block.event.start.toLocalDateTime(zone)
|
||||
val span = wallClockSpan(block.event, zone)
|
||||
source = block
|
||||
resizeEdge = edge
|
||||
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)
|
||||
resizeDay = span.day
|
||||
resizeStartMin = span.startMin
|
||||
resizeEndMin = span.endMin
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
@@ -692,7 +731,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(start.date.plus(edgeDay, DateTimeUnit.DAY))
|
||||
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
|
||||
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||
@@ -709,35 +748,18 @@ 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
|
||||
}
|
||||
@@ -860,22 +882,13 @@ 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 zone = dragZone ?: return
|
||||
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 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 {
|
||||
@@ -963,7 +976,10 @@ fun rememberTimelineDragController(): TimelineDragController {
|
||||
* so the offset has to come back off at the drop (#253).
|
||||
*/
|
||||
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
||||
return (startMin - event.start.minutesPast(day, zone)).coerceAtLeast(0)
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -985,41 +1001,30 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
||||
* visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone, keepOffsetOf = event.start)
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone)
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
/** [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)
|
||||
|
||||
/**
|
||||
* [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 {
|
||||
/** 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)
|
||||
val kept = keepOffsetOf?.takeIf { it.inRepeatedHour(zone) }?.let(zone::offsetAt)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).resolveIn(zone, kept)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60))
|
||||
}
|
||||
|
||||
/** 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))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1271,7 +1276,7 @@ private fun DragCopy(
|
||||
Text(
|
||||
text = label,
|
||||
// As the block it lifted off sets its own time (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()),
|
||||
maxLines = timeMaxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
@@ -1292,7 +1297,8 @@ 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)
|
||||
}
|
||||
return raise.value
|
||||
// The effect runs a frame late, and that frame would show the block unlifted.
|
||||
return if (selected && snap) 1f else raise.value
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,7 +102,6 @@ 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
|
||||
@@ -808,13 +807,12 @@ 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 "$startTime – $endTime"
|
||||
dateFull.format(startLdt.toLocalDate()) to
|
||||
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
|
||||
} else {
|
||||
val start = "${dateMedium.format(startLdt.toLocalDate())} $startTime"
|
||||
val end = "${dateMedium.format(endLdt.toLocalDate())} $endTime"
|
||||
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
|
||||
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
|
||||
"$start – $end" to null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +116,10 @@ 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
|
||||
@@ -165,7 +162,6 @@ 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
|
||||
@@ -178,6 +174,7 @@ 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
|
||||
@@ -187,7 +184,6 @@ 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
|
||||
@@ -205,6 +201,7 @@ fun EventEditScreen(
|
||||
onSaved: () -> Unit,
|
||||
editKey: LongArray? = null,
|
||||
initialStartMinutes: Int? = null,
|
||||
initialEndMinutes: Int? = null,
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
@@ -224,7 +221,7 @@ fun EventEditScreen(
|
||||
else -> {
|
||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
viewModel.openNew(date, initialStartMinutes)
|
||||
viewModel.openNew(date, initialStartMinutes, initialEndMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -666,14 +663,9 @@ 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 },
|
||||
@@ -683,8 +675,6 @@ 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 },
|
||||
@@ -703,14 +693,20 @@ 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 unless the
|
||||
// event is pinned somewhere other than here.
|
||||
if (form.isPinnedAwayFrom(TimeZone.currentSystemDefault())) {
|
||||
// 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) ->
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_edit_timezone_local_time,
|
||||
formatTimeRange(startAt, endAt, TimeZone.currentSystemDefault(), locale),
|
||||
formatTimeRange(localStart, localEnd, locale),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -774,7 +770,7 @@ private fun EventEditContent(
|
||||
timeZoneOptionOf(
|
||||
id,
|
||||
locale,
|
||||
at = form.startInstant(zone).toJavaInstant(),
|
||||
at = form.start.toInstant(zone).toJavaInstant(),
|
||||
regionOf = ::icuTimeZoneRegion,
|
||||
)
|
||||
}
|
||||
@@ -2256,21 +2252,18 @@ private fun EditCard(
|
||||
}
|
||||
|
||||
/**
|
||||
* "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.
|
||||
* "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.
|
||||
*/
|
||||
@Composable
|
||||
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
|
||||
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
|
||||
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)}"
|
||||
val from = timeFormat.format(start.time.toJavaLocalTime())
|
||||
val to = timeFormat.format(end.time.toJavaLocalTime())
|
||||
return if (from == to) from else "$from – $to"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2352,8 +2345,6 @@ private fun firstLineHeight(
|
||||
private fun ScheduleRow(
|
||||
label: String,
|
||||
dateTime: LocalDateTime,
|
||||
instant: Instant,
|
||||
zone: TimeZone,
|
||||
isAllDay: Boolean,
|
||||
locale: Locale,
|
||||
onDateClick: () -> Unit,
|
||||
@@ -2396,7 +2387,7 @@ private fun ScheduleRow(
|
||||
)
|
||||
if (!isAllDay) {
|
||||
Text(
|
||||
text = withRepeatedHourZone(timeFormat.format(dateTime.time.toJavaLocalTime()), instant, zone, locale),
|
||||
text = timeFormat.format(dateTime.time.toJavaLocalTime()),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
|
||||
@@ -19,12 +19,6 @@ 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
|
||||
@@ -32,6 +26,7 @@ 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
|
||||
@@ -55,7 +50,6 @@ 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
|
||||
@@ -296,11 +290,7 @@ class EventEditViewModel @Inject constructor(
|
||||
EventEditUiState(
|
||||
form = resolved,
|
||||
calendars = pickerCalendars,
|
||||
problems = if (local.showProblems) {
|
||||
resolved.problems(TimeZone.currentSystemDefault())
|
||||
} else {
|
||||
emptySet()
|
||||
},
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||
@@ -335,31 +325,32 @@ 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) {
|
||||
fun openNew(date: LocalDate, startMinutes: Int? = null, endMinutes: Int? = null) {
|
||||
if (_form.value != null) return
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val now = Clock.System.now()
|
||||
val startAt = when {
|
||||
val start = when {
|
||||
startMinutes != null ->
|
||||
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60)).toInstant(zone)
|
||||
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
|
||||
date == now.toLocalDateTime(zone).date -> {
|
||||
// Today: the next full hour (may roll into tomorrow before midnight).
|
||||
val hourMillis = 3_600_000L
|
||||
Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
|
||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||
}
|
||||
else -> LocalDateTime(date, LocalTime(9, 0)).toInstant(zone)
|
||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||
}
|
||||
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),
|
||||
)
|
||||
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
|
||||
applyDefaultReminder()
|
||||
applyDefaultDuration()
|
||||
}
|
||||
@@ -469,10 +460,11 @@ 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 ->
|
||||
val zone = form.resolvedZone(TimeZone.currentSystemDefault())
|
||||
val start = form.startInstant(zone)
|
||||
form.withTimes(start, start + duration.minutes, zone)
|
||||
form.copy(
|
||||
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,12 +669,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.withEnd(LocalDateTime(date, it.end.time)) }
|
||||
update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||
}
|
||||
|
||||
fun setEndTime(time: LocalTime) {
|
||||
_durationTouched.value = true
|
||||
update { it.withEnd(LocalDateTime(it.end.date, time)) }
|
||||
update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -695,7 +687,7 @@ class EventEditViewModel @Inject constructor(
|
||||
val current = state.value ?: return
|
||||
if (current.saveState == SaveUiState.Saving) return
|
||||
val form = current.form
|
||||
if (form.problems(TimeZone.currentSystemDefault()).isNotEmpty()) {
|
||||
if (form.problems().isNotEmpty()) {
|
||||
_showProblems.value = true
|
||||
return
|
||||
}
|
||||
@@ -824,13 +816,11 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
private inline fun update(block: (EventForm) -> EventForm) {
|
||||
|
||||
@@ -52,7 +52,6 @@ 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
|
||||
@@ -65,6 +64,16 @@ 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
|
||||
@@ -240,6 +249,7 @@ 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(),
|
||||
@@ -254,6 +264,7 @@ fun TimelineScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onCreateSpan = onCreateSpan,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -300,12 +311,6 @@ 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
|
||||
|
||||
@@ -317,6 +322,7 @@ 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.
|
||||
@@ -349,6 +355,7 @@ 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).
|
||||
@@ -442,8 +449,7 @@ 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.
|
||||
// The timeline's own zone, so "today" agrees with the blocks and drops (#387).
|
||||
val zone = viewModel.zone
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
||||
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
|
||||
|
||||
@@ -484,10 +490,7 @@ private fun PagedTimelineScreen(
|
||||
)
|
||||
|
||||
ScreenBody(modifier = modifier) { innerPadding ->
|
||||
CompositionLocalProvider(
|
||||
LocalDimCutoff provides dimCutoff,
|
||||
LocalTimelineZone provides viewModel.zone,
|
||||
) {
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
WeekContent(
|
||||
state = state,
|
||||
today = today,
|
||||
@@ -501,6 +504,8 @@ private fun PagedTimelineScreen(
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
onCreateSpan = onCreateSpan,
|
||||
draftMinutes = draftMinutes,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
@@ -523,6 +528,8 @@ 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()
|
||||
@@ -537,33 +544,39 @@ 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 = LocalTimelineZone.current
|
||||
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||
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
|
||||
}
|
||||
// A selected event stays selected where it landed (#141).
|
||||
if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
|
||||
val start = drop.startInstant(zone)
|
||||
dragController.select(
|
||||
drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start)),
|
||||
moved = true,
|
||||
)
|
||||
dragController.select(moved, moved = true)
|
||||
}
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
target = MoveTarget.Start(start),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A resize keeps its event selected, at its new length (#141).
|
||||
val onResize: (TimelineResizeDrop) -> Unit = { drop ->
|
||||
dragController.select(
|
||||
drop.event.copy(start = drop.start, end = drop.end),
|
||||
moved = true,
|
||||
)
|
||||
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)
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
@@ -574,6 +587,13 @@ 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(
|
||||
@@ -581,10 +601,15 @@ 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 { dragController.deselect() }
|
||||
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()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() }
|
||||
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
|
||||
@@ -598,9 +623,14 @@ private fun WeekContent(
|
||||
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
||||
dragController.deselect()
|
||||
}
|
||||
val requireWrite = rememberWriteGate()
|
||||
val openEvent: (EventInstance) -> Unit = { event ->
|
||||
dragController.deselect()
|
||||
onEventClick(event)
|
||||
if (event.isDraft) {
|
||||
requireWrite { createDraft(event) }
|
||||
} else {
|
||||
dragController.deselect()
|
||||
onEventClick(event)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
@@ -1380,10 +1410,16 @@ private fun DayColumnCard(
|
||||
val hourPx = with(density) { hourHeight.toPx() }
|
||||
val morphing = LocalViewMorph.current != null
|
||||
val showHourGrid = LocalShowHourGrid.current
|
||||
val zone = LocalTimelineZone.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 }
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
LaunchedEffect(shown, dragController.settling) {
|
||||
dragController.noteGrid(date, shown)
|
||||
}
|
||||
// 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
|
||||
@@ -1393,6 +1429,7 @@ 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.
|
||||
@@ -1431,14 +1468,17 @@ 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>()
|
||||
blocks.forEach { block ->
|
||||
shown.forEach { block ->
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
@@ -1449,6 +1489,7 @@ 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.
|
||||
@@ -1535,10 +1576,8 @@ 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(labelEndMin, use24Hour, locale)
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val density = LocalDensity.current
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||
@@ -1552,7 +1591,7 @@ private fun EventBlock(
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
val shownTime = rememberBlockTimeRange(
|
||||
minToHm(block.startMin, use24Hour, locale),
|
||||
minToHm(labelEndMin, use24Hour, locale),
|
||||
minToHm(block.endMin, use24Hour, locale),
|
||||
textWidth,
|
||||
)
|
||||
// A short block drops the title rather than serving a horizontally sliced
|
||||
@@ -1583,8 +1622,10 @@ 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 draggable = eventDragAllowed(block.event)
|
||||
val draft = block.event.isDraft
|
||||
val draggable = draft || eventDragAllowed(block.event)
|
||||
val selectable = eventSelectAllowed()
|
||||
val selected = dragController.isSelected(block)
|
||||
val haptics = LocalHapticFeedback.current
|
||||
@@ -1603,7 +1644,6 @@ 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
|
||||
@@ -1627,7 +1667,12 @@ 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 = if (takesOverLift && !lifted) 1f else ghostFade
|
||||
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 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() }
|
||||
@@ -1702,9 +1747,10 @@ 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.fastSpatialSpec(),
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "handles",
|
||||
)
|
||||
val handleAlpha = Modifier.graphicsLayer {
|
||||
@@ -1744,6 +1790,62 @@ 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,7 +35,6 @@ 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
|
||||
@@ -82,8 +81,7 @@ abstract class PagedTimelineViewModel(
|
||||
initialRule: (LocalDate) -> PageGrid,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The zone every page is laid out in; drags and drops resolve in it too (#387). */
|
||||
val zone: TimeZone = TimeZone.currentSystemDefault()
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
@@ -93,6 +91,14 @@ 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()
|
||||
|
||||
@@ -333,62 +339,6 @@ 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) {
|
||||
@@ -442,14 +392,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 = wallClockMinutes(ev.start, day, zone)
|
||||
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
||||
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 = wallClockMinutes(s, day, zone)
|
||||
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
|
||||
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
||||
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
|
||||
Raw(ev, startMin, endMin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@
|
||||
<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,10 +9,8 @@ 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 {
|
||||
|
||||
@@ -1389,37 +1387,4 @@ 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(berlin)).isEmpty()
|
||||
assertThat(form().problems()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing calendar is a problem`() {
|
||||
assertThat(form(calendarId = null).problems(berlin))
|
||||
assertThat(form(calendarId = null).problems())
|
||||
.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(berlin)).containsExactly(EventFormProblem.EndBeforeStart)
|
||||
assertThat(bad.problems()).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(berlin)).isEmpty()
|
||||
assertThat(instant.problems()).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(berlin)).isEmpty()
|
||||
assertThat(allDay.problems()).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(berlin)).containsExactly(EventFormProblem.EndBeforeStart)
|
||||
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -66,7 +66,7 @@ class EventFormTest {
|
||||
calendarId = null,
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
||||
)
|
||||
assertThat(bad.problems(berlin)).containsExactly(
|
||||
assertThat(bad.problems()).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(berlin))
|
||||
assertThat(bad.problems())
|
||||
.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(berlin)).isEmpty()
|
||||
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems(berlin)).isEmpty()
|
||||
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
|
||||
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).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(berlin)).isEmpty()
|
||||
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).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(berlin))
|
||||
assertThat(bad.problems())
|
||||
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||
}
|
||||
|
||||
@@ -210,23 +210,64 @@ class EventFormTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isPinnedAwayFrom is false when there is nothing to disambiguate`() {
|
||||
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`() {
|
||||
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.isPinnedAwayFrom(berlin)).isFalse()
|
||||
assertThat(base.timesIn(berlin)).isNull()
|
||||
// Pinned to the target itself: same thing.
|
||||
assertThat(base.copy(timezone = "Europe/Berlin").isPinnedAwayFrom(berlin)).isFalse()
|
||||
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
|
||||
// All-day: date-anchored, so there's no zone conversion to show.
|
||||
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").isPinnedAwayFrom(berlin))
|
||||
.isFalse()
|
||||
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
|
||||
.isNull()
|
||||
// Unparseable: can't convert, mustn't throw.
|
||||
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()
|
||||
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -384,43 +425,4 @@ 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,10 +5,8 @@ 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 {
|
||||
|
||||
@@ -90,10 +88,8 @@ class EventShiftTest {
|
||||
|
||||
@Test
|
||||
fun `a zero-distance drop leaves the form untouched`() {
|
||||
// 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)
|
||||
val original = form()
|
||||
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -201,35 +197,4 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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,15 +4,12 @@ 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
|
||||
|
||||
/**
|
||||
@@ -156,88 +153,4 @@ 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,108 +190,5 @@ 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user