From edd4bf965a2cd1502c18f91937a2a525737848dd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 6 Nov 2026 10:13:03 +0100 Subject: [PATCH] Follow-ups for whole-day events and event status (#296, #388) (#484) Drag all-day strip events between days or back into the grid, show cancelled one-off events, review fixes, floret-kit pinned to main. --- .../data/calendar/CalendarDataSource.kt | 39 +++-- .../data/calendar/EventWriteMapper.kt | 11 +- .../calendula/data/calendar/InstanceMapper.kt | 7 + .../calendula/data/calendar/Projections.kt | 22 +++ .../data/reminders/ReminderTimeText.kt | 15 +- .../calendula/domain/EventDisplayOrder.kt | 16 +- .../calendula/domain/EventForm.kt | 18 +- .../calendula/domain/EventShift.kt | 9 +- .../jeanlucmakiola/calendula/domain/Models.kt | 7 +- .../calendula/domain/ics/ParsedIcsForm.kt | 3 +- .../calendula/ui/common/EventMoveHost.kt | 2 +- .../ui/common/RescheduleViewModel.kt | 46 +++--- .../calendula/ui/common/TimelineDrag.kt | 154 +++++++++++++++++- .../calendula/ui/edit/EventEditScreen.kt | 5 +- .../calendula/ui/edit/EventEditViewModel.kt | 8 +- .../calendula/ui/search/SearchScreen.kt | 3 +- .../calendula/ui/week/TimelineActions.kt | 5 + .../calendula/ui/week/WeekScreen.kt | 145 ++++++++++++++++- .../calendula/ui/week/WeekViewModel.kt | 16 +- .../calendula/ui/week/WeekViewPreview.kt | 8 +- .../calendula/widget/split/MonthDayWidget.kt | 5 +- .../calendula/widget/week/WeekStripWidget.kt | 6 +- app/src/main/res/values/strings.xml | 1 + .../data/calendar/EventWriteMapperTest.kt | 12 ++ .../data/calendar/ImportedEventValuesTest.kt | 7 + .../data/calendar/InstanceMapperTest.kt | 9 + .../calendula/domain/EventDisplayOrderTest.kt | 32 ++++ .../calendula/domain/EventFormTest.kt | 17 +- .../calendula/domain/EventShiftTest.kt | 8 +- .../ui/common/RescheduleViewModelTest.kt | 80 ++++++++- .../calendula/ui/week/WeekLayoutTest.kt | 27 ++- floret-kit | 2 +- 32 files changed, 640 insertions(+), 105 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrderTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 36ce793..7f96a33 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -567,8 +567,31 @@ class AndroidCalendarDataSource @Inject constructor( } } - override fun instances(beginMillis: Long, endMillis: Long): List = - queryInstances(beginMillis, endMillis, null, null) + override fun instances(beginMillis: Long, endMillis: Long): List { + val listed = queryInstances(beginMillis, endMillis, null, null) + // A provider that does expand cancelled events already listed them. + val shown = listed.mapTo(HashSet()) { it.eventId } + val cancelled = queryCancelledOneOffs(beginMillis, endMillis).filter { it.eventId !in shown } + return if (cancelled.isEmpty()) listed else (listed + cancelled).sortedBy { it.start } + } + + /** Cancelled one-off events overlapping the window, which AOSP leaves out of Instances (#388). */ + private fun queryCancelledOneOffs(beginMillis: Long, endMillis: Long): List { + val selection = "${CalendarContract.Events.STATUS} = ${CalendarContract.Events.STATUS_CANCELED} AND " + + "${CalendarContract.Events.DELETED} = 0 AND " + + "${CalendarContract.Events.RRULE} IS NULL AND ${CalendarContract.Events.RDATE} IS NULL AND " + + "${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " + + "${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL AND " + + "${CalendarContract.Events.DTSTART} < ? AND " + + "COALESCE(${CalendarContract.Events.DTEND}, ${CalendarContract.Events.DTSTART}) >= ?" + return resolver.query( + CalendarContract.Events.CONTENT_URI, + CancelledEventProjection.COLUMNS, + selection, + arrayOf(endMillis.toString(), beginMillis.toString()), + null, + )?.use { c -> c.mapRowsNotNull { toCancelledOneOff() } } ?: emptyList() + } override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List = // The series' own rows plus its exceptions, so an occurrence edited on @@ -587,13 +610,8 @@ class AndroidCalendarDataSource @Inject constructor( args: Array?, ): List { ensureObserversRegistered() - // Hide cancelled occurrences: "delete only this event" writes a - // cancelled exception for the one instance (#47). A cancelled event or - // series of its own stays, crossed out (#388), so only exception rows - // are filtered. A NULL status is a normal, un-cancelled event, so it - // must survive the filter — a bare `!= CANCELED` would drop it (NULL != - // 2 is NULL, not true). DELETED = 0: a provider that soft-deletes may - // keep the row's instances until a purge (#364). + // Hide deleted occurrences (cancelled exceptions, #47) but keep a cancelled + // event of its own (#388); NULL status is a normal event. DELETED: #364. val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " + "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED} OR " + "(${CalendarContract.Instances.ORIGINAL_ID} IS NULL AND " + @@ -627,7 +645,8 @@ class AndroidCalendarDataSource @Inject constructor( .toTypedArray() val selection = "($match) AND " + "${CalendarContract.Events.DELETED} = 0 AND " + - "${CalendarContract.Events.ORIGINAL_ID} IS NULL" + "${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " + + "${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL" return resolver.query( CalendarContract.Events.CONTENT_URI, SearchProjection.COLUMNS, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 3fe03f9..1317e9c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.FormTime import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.forNewEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.nearestTo import kotlinx.datetime.toJavaLocalDate @@ -132,7 +133,8 @@ internal fun buildEventInsertValues( put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) - if (form.status != EventStatus.Confirmed) put(CalendarContract.Events.STATUS, form.status.toProviderStatus()) + form.status.forNewEvent().takeIf { it != EventStatus.Confirmed } + ?.let { put(CalendarContract.Events.STATUS, it.toProviderStatus()) } form.location.trim().takeIf { it.isNotEmpty() } ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } form.description.trim().takeIf { it.isNotEmpty() } @@ -283,7 +285,7 @@ internal fun buildOccurrenceExceptionValues( put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) - put(CalendarContract.Events.STATUS, form.status.toProviderStatus()) + put(CalendarContract.Events.STATUS, form.status.forNewEvent().toProviderStatus()) put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null }) if (form.colorKey == null) putAll(eventColorColumns(null, form.color)) @@ -359,7 +361,8 @@ internal fun buildMovedMasterValues( } put(CalendarContract.Events.AVAILABILITY, snapshot.availability) put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel) - snapshot.status?.let { put(CalendarContract.Events.STATUS, it) } + snapshot.status?.takeIf { it != CalendarContract.Events.STATUS_CANCELED } + ?.let { put(CalendarContract.Events.STATUS, it) } put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null }) } @@ -863,7 +866,7 @@ internal fun buildImportedEventValues( // timed rows keep the event's own zone. put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId) put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue()) - put(CalendarContract.Events.STATUS, event.status.toProviderStatus()) + put(CalendarContract.Events.STATUS, event.status.forNewEvent().toProviderStatus()) event.location?.trim()?.takeIf { it.isNotEmpty() } ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } event.description?.trim()?.takeIf { it.isNotEmpty() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index 8d6cb7a..ff4d9bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -42,6 +42,13 @@ internal fun ColumnReader.toEventInstance(): EventInstance? { ) } +/** + * A [CancelledEventProjection] row as an instance. Its id is the negated event + * id, so it can't collide with a provider instance id. + */ +internal fun ColumnReader.toCancelledOneOff(): EventInstance? = + toEventInstance()?.let { it.copy(instanceId = -it.eventId) } + /** * `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe" * counts as going, and everything that is not an open or refused invitation — diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 69dc43c..5775d1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -79,6 +79,28 @@ internal object InstanceProjection { const val IDX_STATUS = 11 } +/** + * A cancelled one-off straight from the Events table, laid out on + * [InstanceProjection]'s indices so [toCancelledOneOff] reuses its mapper. AOSP + * writes no Instances rows for a cancelled event (#388). + */ +internal object CancelledEventProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events._ID, + CalendarContract.Events.CALENDAR_ID, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.EVENT_COLOR, + CalendarContract.Events.CALENDAR_COLOR, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.SELF_ATTENDEE_STATUS, + CalendarContract.Events.STATUS, + ) +} + internal object EventDetailProjection { val COLUMNS: Array = arrayOf( CalendarContract.Events._ID, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt index 138f836..9e983d8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.reminders +import de.jeanlucmakiola.calendula.domain.runsMidnightToMidnight import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import java.time.DayOfWeek import java.time.Instant @@ -11,6 +12,8 @@ import java.time.format.FormatStyle import java.time.format.TextStyle import java.time.temporal.ChronoUnit import java.util.Locale +import kotlinx.datetime.toKotlinTimeZone +import kotlin.time.Instant as KInstant /** * The one line of time context in a reminder notification. Pure so it can be @@ -48,8 +51,11 @@ fun reminderTimeText( tomorrowLabel: String, yesterdayLabel: String, ): String { - val wholeDays = !isAllDay && endMillis > beginMillis && - isStartOfDay(beginMillis, zone) && isStartOfDay(endMillis, zone) + val wholeDays = !isAllDay && runsMidnightToMidnight( + KInstant.fromEpochMilliseconds(beginMillis), + KInstant.fromEpochMilliseconds(endMillis), + zone.toKotlinTimeZone(), + ) if (isAllDay || wholeDays) { val dayZone = if (isAllDay) ZoneOffset.UTC else zone val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) @@ -138,8 +144,3 @@ private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate { } private const val RANGE = " – " - -private fun isStartOfDay(millis: Long, zone: ZoneId): Boolean { - val at = Instant.ofEpochMilli(millis).atZone(zone) - return at.toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() == millis -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt index 483464f..3c7a402 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.calendula.domain import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.time.Instant /** Lower values appear first in lists and get the upper visible lanes in grids. */ fun EventInstance.displayPriority(zone: TimeZone): Int = when { @@ -15,11 +17,19 @@ fun EventInstance.displayPriority(zone: TimeZone): Int = when { * Each event's priority is worked out once, not on every comparison. */ fun List.sortedForDisplay(zone: TimeZone): List = - map { it to it.displayPriority(zone) } + map { Triple(it, it.displayPriority(zone), it.displayStart(zone)) } .sortedWith( - compareBy> { it.second } - .thenBy { it.first.start } + compareBy> { it.second } + .thenBy { it.third } .thenBy { it.first.title } .thenBy { it.first.instanceId }, ) .map { it.first } + +/** + * Where an event starts for ordering: a whole-day event at local midnight of its + * first day, since an all-day one is stored at UTC midnight and would otherwise + * sort around timed events by the device's offset (#296). + */ +internal fun EventInstance.displayStart(zone: TimeZone): Instant = + if (coversWholeDays(zone)) spanFirstDay(zone).atStartOfDayIn(zone) else start diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 35e7d00..3afbed5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -248,11 +248,20 @@ internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair start && start.isStartOfDay(zone) && end.isStartOfDay(zone)) +fun EventInstance.coversWholeDays(zone: TimeZone): Boolean = isAllDay || runsMidnightToMidnight(start, end, zone) + +/** Whether a timed span from [start] to [end] runs from local midnight to local midnight in [zone] (#296). */ +fun runsMidnightToMidnight(start: Instant, end: Instant, zone: TimeZone): Boolean = + end > start && start.isStartOfDay(zone) && end.isStartOfDay(zone) private fun Instant.isStartOfDay(zone: TimeZone): Boolean = this == toLocalDateTime(zone).date.atStartOfDayIn(zone) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt index b2db413..436e8ac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.allDayFormTimes +import de.jeanlucmakiola.calendula.domain.forNewEvent import de.jeanlucmakiola.calendula.domain.normalizedRrule import de.jeanlucmakiola.calendula.domain.toFormTime import kotlinx.datetime.TimeZone @@ -35,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { description = description.orEmpty(), reminders = semanticReminderMinutes(), availability = availability, - status = status, + status = status.forNewEvent(), rrule = recurrenceRule.normalizedRrule(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index 27d3ee7..f827dc9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -68,7 +68,6 @@ fun EventMoveHost( ), onSelect = viewModel::moveWithScope, onDismiss = viewModel::cancelScope, - allowOccurrence = !pending.seriesOnly, allowSeries = !pending.occurrenceOnly, reason = stringResource(R.string.event_move_occurrence_only) .takeIf { pending.occurrenceOnly }, @@ -92,6 +91,7 @@ fun EventMoveHost( MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied) MoveOutcome.Gone -> stringResource(R.string.event_move_gone) MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end) + MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series) MoveOutcome.Failed -> stringResource(R.string.event_move_failed) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index 66864c4..dad5cb1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException -import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence +import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormProblem @@ -58,7 +58,10 @@ sealed interface MoveTarget { */ data class ByDays(val days: Int) : MoveTarget - /** New start and end instants — a timeline resize, which changes the length. */ + /** + * New start and end instants — a timeline resize, which changes the length, + * or an all-day event dragged into the time grid, which becomes timed. + */ data class Span(val start: Instant, val end: Instant) : MoveTarget } @@ -80,12 +83,7 @@ data class MoveRequest( * re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor * across the same midnights the occurrence crossed. */ -data class MoveScopePrompt( - val occurrenceOnly: Boolean, - val resize: Boolean = false, - /** A cancelled series, whose occurrences can't move on their own (#388). */ - val seriesOnly: Boolean = false, -) +data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false) /** The inverse of a completed move, for the undo action. */ data class MoveUndo( @@ -120,6 +118,12 @@ sealed interface MoveOutcome { /** The drop would push the series past its own `UNTIL`, generating nothing. */ data object BlockedSeriesEnd : MoveOutcome + /** + * A cancelled series only moves as a whole, and this drop would change which + * days it falls on (#388). + */ + data object BlockedCancelledSeries : MoveOutcome + data object Failed : MoveOutcome } @@ -209,7 +213,10 @@ class RescheduleViewModel @Inject constructor( val canRealign: Boolean, /** The series row's DTSTART date after the move — what its `UNTIL` must clear. */ val newAnchorDate: LocalDate, - ) + ) { + /** A timed event's new length, rather than an all-day one dragged into the grid. */ + val isResize: Boolean get() = request.target is MoveTarget.Span && !original.isAllDay + } /** * Take a drop, unless one is already being written. False means nothing will @@ -233,12 +240,13 @@ class RescheduleViewModel @Inject constructor( busy = false return@launch } - val seriesOnly = !prepared.original.canSaveAsOccurrence() - if (!prepared.isRecurring) { + // A cancelled series only moves as a whole (#388). + val seriesOnly = !prepared.original.canSaveApartFromSeries() + if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) { write(prepared, RecurringWriteScope.AllEvents) busy = false - } else if (seriesOnly && !prepared.canRealign) { - // Neither the occurrence nor the series can take this move. + } else if (seriesOnly) { + _outcome.value = MoveOutcome.BlockedCancelledSeries _abandoned.value += 1 busy = false } else { @@ -246,8 +254,7 @@ class RescheduleViewModel @Inject constructor( pending = prepared _scopePrompt.value = MoveScopePrompt( occurrenceOnly = !prepared.canRealign, - resize = request.target is MoveTarget.Span, - seriesOnly = seriesOnly, + resize = prepared.isResize, ) } } @@ -259,7 +266,6 @@ class RescheduleViewModel @Inject constructor( val prepared = pending ?: return // Guard against the dialog ever offering a scope the rule can't carry. if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return - if (scope == RecurringWriteScope.ThisEvent && !prepared.original.canSaveAsOccurrence()) return pending = null _scopePrompt.value = null viewModelScope.launch { @@ -472,9 +478,9 @@ class RescheduleViewModel @Inject constructor( val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone) val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone) .time.toMillisecondOfDay() / MILLIS_PER_MINUTE - val delta = ( - shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay() - ) / MILLIS_PER_MINUTE + // An all-day form's time is a placeholder; its anchor sits at midnight. + val fromMillis = if (original.isAllDay) 0 else original.start.time.toMillisecondOfDay() + val delta = (shifted.start.time.toMillisecondOfDay() - fromMillis) / MILLIS_PER_MINUTE return anchorMinute + delta in 0 until MINUTES_PER_DAY } @@ -507,7 +513,7 @@ class RescheduleViewModel @Inject constructor( /** Where a resized occurrence now ends; null for a move, which keeps its length. */ private fun resizedEndMillis(prepared: PreparedMove): Long? { - if (prepared.request.target !is MoveTarget.Span) return null + if (!prepared.isResize) return null val form = prepared.updated return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index bd264e8..3c0c6df 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -78,6 +78,9 @@ import kotlin.time.Instant /** Start granularity a dragged block snaps to, matching the big calendar apps. */ const val DRAG_SNAP_MINUTES: Int = 15 +/** How far below a strip drag's timed block top the finger holds it, in minutes. */ +private const val STRIP_GRAB_MINUTES = 15 + /** How close to a timeline edge the finger must get before the view scrolls. */ private val AUTO_SCROLL_EDGE = 64.dp @@ -126,6 +129,21 @@ data class TimelineDrag( val slot: Triple get() = Triple(date, startMin, eventSpanMin) } +/** + * An event lifted off the all-day strip: held over the strip it moves [dayShift] + * days and stays whole-day, held over the time grid it becomes a timed block. + */ +data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGrid: Boolean = false) + +/** Where a strip drag asks its event to go. */ +sealed interface StripDrop { + val event: EventInstance + + data class Days(override val event: EventInstance, val days: Int) : StripDrop + + data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop +} + /** The event a long press left selected (#141). */ data class TimelineSelection( val event: EventInstance, @@ -629,6 +647,28 @@ class TimelineDragController { /** The resize in progress, or null for a plain drag (#141). */ private var resize: ResizeSession? = null + /** The strip drag in progress; over the grid it drives [drag] through a timed stand-in. */ + var stripDrag: StripDrag? by mutableStateOf(null) + private set + + /** A strip drop held at its new days until the strip redraws the event there. */ + var stripSettling: StripDrag? by mutableStateOf(null) + private set + + /** Whether the strip now draws [stripSettling] at its new days itself. */ + var stripSeated: Boolean by mutableStateOf(false) + private set + + /** What [abandonedTicks] stood at when [stripSettling] was let go. */ + var stripAbandonedMark: Int = 0 + private set + + /** The shown day the finger picked the strip bar up on. */ + private var stripGrabIndex = 0 + + /** Whether a strip drag over the grid has cleared its top edge, and may now scroll up. */ + private var stripScrollArmed = false + fun begin( block: TimedBlock, clipOffsetMin: Int, @@ -667,6 +707,69 @@ class TimelineDragController { begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot) } + /** + * Lift [event] off the all-day strip. Over the time grid it is dragged as a + * block [draftMinutes] long, whose top sits a little above the finger. + */ + fun beginStrip(event: EventInstance, zone: TimeZone, pointerInRoot: Offset) { + val grid = geometry.grid?.takeIf { it.isAttached } ?: return + if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return + deselect() + if (stripSettling?.event?.instanceId == event.instanceId) releaseStrip() + stripScrollArmed = false + val timed = event.copy(isAllDay = false, end = event.start + draftMinutes.minutes) + source = TimedBlock(timed, 0, draftMinutes, lane = 0, laneCount = 1) + clipOffsetMin = 0 + dragZone = zone + dragDuration = draftMinutes.minutes + dragKept = null + titleLines = 1 + settling = null + isDragging = true + liftedInstanceId = event.instanceId + grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f) + pointer = pointerInRoot + pickupPointer = pointerInRoot + pointerMoved = false + originSlot = null + stripGrabIndex = dayIndexAt(pointerInRoot.x, grid.positionInRoot().x) + stripDrag = StripDrag(event) + } + + /** The shown day under [x], clamped to the days on screen. */ + private fun dayIndexAt(x: Float, originX: Float): Int = + column(((x - originX) / geometry.columnWidthPx).toInt().coerceIn(0, geometry.days.lastIndex)) + + /** End a strip drag, handing back where it landed — null when it went nowhere. */ + fun finishStrip(): StripDrop? { + val held = stripDrag ?: return null + val landed = drag + val zone = dragZone + cancel() + if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) { + settle(landed) + val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone) + return StripDrop.Timed(held.event, start, start + draftMinutes.minutes) + } + if (held.overGrid || held.dayShift == 0) return null + stripSettling = held + stripSeated = false + stripAbandonedMark = abandonedTicks?.value ?: 0 + drops++ + return StripDrop.Days(held.event, held.dayShift) + } + + /** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */ + fun releaseStrip() { + stripSettling = null + stripSeated = false + } + + /** Note that the strip draws the settling drop at its new days. */ + fun seatStrip() { + if (stripSettling != null) stripSeated = true + } + fun move(pointerInRoot: Offset) { pointer = pointerInRoot if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true @@ -675,6 +778,7 @@ class TimelineDragController { fun cancel() { resize = null + stripDrag = null source = null clipOffsetMin = 0 dragZone = null @@ -755,15 +859,10 @@ class TimelineDragController { /** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */ private fun settle(landed: TimelineDrag, end: Instant? = null) { - // Landed on midnight to midnight, the event moves to the all-day strip - // (#296): no block will seat its copy or carry the selection. - val leavesGrid = landed.eventSpanMin > 0 && + // Landed on midnight to midnight, the event moves to the all-day strip (#296). + val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 && landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 && landed.eventSpanMin % MINUTES_PER_DAY == 0 - if (leavesGrid && !landed.event.isDraft && selection?.event?.instanceId == landed.event.instanceId) { - selection = null - selectionCovered = false - } 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 @@ -937,6 +1036,29 @@ class TimelineDragController { recomputeResize(it) return } + stripDrag?.let { + recomputeStrip(it) + return + } + recomputeDrag() + } + + /** Over the strip the bar moves by whole days; past its bottom edge it is a timed block. */ + private fun recomputeStrip(held: StripDrag) { + val grid = geometry.grid?.takeIf { it.isAttached } ?: return + val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return + if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return + if (pointer.y >= viewport.boundsInRoot().top) { + stripDrag = held.copy(overGrid = true) + recomputeDrag() + } else { + drag = null + val shift = dayIndexAt(pointer.x, grid.positionInRoot().x) - stripGrabIndex + stripDrag = held.copy(dayShift = shift, overGrid = false) + } + } + + private fun recomputeDrag() { val block = source ?: return val grid = geometry.grid?.takeIf { it.isAttached } ?: return val hourPx = geometry.hourPx @@ -1022,11 +1144,18 @@ class TimelineDragController { } private fun edgeStep(): Float { + val strip = stripDrag + if (strip?.overGrid == false) return 0f val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f val edge = geometry.edgePx if (edge <= 0f) return 0f val bounds = viewport.boundsInRoot() val fromTop = pointer.y - bounds.top + // Coming down out of the strip the finger starts inside the top edge. + if (strip != null && !stripScrollArmed) { + if (fromTop < edge) return 0f + stripScrollArmed = true + } val fromBottom = bounds.bottom - pointer.y return when { fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx @@ -1186,7 +1315,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = // Both live here rather than beside the controller: they read [drag], which // changes every frame, and this composable is the one that is meant to. - DragSnapHaptics(controller.drag?.slot, controller.drops) + DragSnapHaptics(controller.drag?.slot ?: controller.stripDrag?.dayShift, controller.drops) // Only once the finger has moved: a long press held near an edge must not // scroll the drop target out from under a finger that only means to select. LaunchedEffect(controller.isDragging && controller.pointerMoved) { @@ -1210,6 +1339,15 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = controller.release() } + // The same hold for a bar moved along the all-day strip. + LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) { + if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect + if (abandoned == controller.stripAbandonedMark) { + delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) + } + controller.releaseStrip() + } + Box( modifier = modifier .fillMaxSize() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 47e7825..86e70e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -118,7 +118,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus @@ -452,12 +451,12 @@ fun EventEditScreen( if (state?.saveState == SaveUiState.AwaitingScope) { // A changed rule rules out the single occurrence — an exception row - // can't carry its own rule — and so does a cancelled one (#388). + // can't carry its own rule. RecurringScopeDialog( title = stringResource(R.string.event_edit_recurring_title), onSelect = viewModel::saveWithScope, onDismiss = viewModel::consumeSaveResult, - allowOccurrence = state?.recurrenceChanged != true && state?.form?.canSaveAsOccurrence() != false, + allowOccurrence = state?.recurrenceChanged != true, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 426186b..5235cb7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -14,7 +14,7 @@ import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder -import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence +import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus @@ -770,7 +770,10 @@ class EventEditViewModel @Inject constructor( // Managed events are a yearly series whose editable fields (reminders, // notes, location) live on the series row — never offer the scope dialog, // which would split the series into an exception the sync then reverts. - if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) { + // A cancelled one only saves as the whole series (#388). + if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar && + form.canSaveApartFromSeries() + ) { _saveState.value = SaveUiState.AwaitingScope return } @@ -781,7 +784,6 @@ class EventEditViewModel @Inject constructor( fun saveWithScope(scope: RecurringWriteScope) { val current = state.value ?: return if (current.saveState != SaveUiState.AwaitingScope) return - if (scope == RecurringWriteScope.ThisEvent && !current.form.canSaveAsOccurrence()) return performSave(current.form, scope) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index df35de1..018cf5e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -94,6 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.eventAccent +import de.jeanlucmakiola.calendula.domain.coversWholeDays import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter @@ -609,7 +610,7 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString .format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate()) } val use24Hour = LocalUse24HourFormat.current - val timeText = if (event.isAllDay) { + val timeText = if (event.coversWholeDays(TimeZone.currentSystemDefault())) { stringResource(R.string.event_detail_all_day) } else { remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt index 2a0e69c..6a338bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.StripDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop @@ -25,6 +26,7 @@ internal class TimelineActions( val onCreateAt: (LocalDate, Int) -> Unit, val onDrop: (TimelineDrop) -> Unit, val onResize: (TimelineResizeDrop) -> Unit, + val onStripDrop: (StripDrop) -> Unit, ) /** The callbacks are read through their latest values, so the holder outlives them. */ @@ -37,12 +39,14 @@ internal fun rememberTimelineActions( onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, onResize: (TimelineResizeDrop) -> Unit, + onStripDrop: (StripDrop) -> Unit, ): TimelineActions { val eventClick = rememberUpdatedState(onEventClick) val openDay = rememberUpdatedState(onOpenDay) val createAt = rememberUpdatedState(onCreateAt) val drop = rememberUpdatedState(onDrop) val resize = rememberUpdatedState(onResize) + val stripDrop = rememberUpdatedState(onStripDrop) return remember(scrollState, dragController) { TimelineActions( scrollState = scrollState, @@ -52,6 +56,7 @@ internal fun rememberTimelineActions( onCreateAt = { date, minutes -> createAt.value(date, minutes) }, onDrop = { drop.value(it) }, onResize = { resize.value(it) }, + onStripDrop = { stripDrop.value(it) }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 43a668c..fa86813 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -106,7 +106,13 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.runtime.State +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Density import androidx.compose.ui.layout.layout import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput @@ -125,6 +131,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.dateZone +import de.jeanlucmakiola.calendula.domain.spanFirstDay +import de.jeanlucmakiola.calendula.domain.spanLastDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.LocalChipGround import de.jeanlucmakiola.calendula.ui.common.eventPaint @@ -147,6 +156,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.TimelineDragController +import de.jeanlucmakiola.calendula.ui.common.StripDrag +import de.jeanlucmakiola.calendula.ui.common.StripDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes @@ -198,6 +209,7 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.plus @@ -601,6 +613,21 @@ private fun WeekContent( ) } + // Off the all-day strip: sideways moves days, into the grid makes it timed. + val onStripDrop: (StripDrop) -> Unit = { drop -> + move?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = when (drop) { + is StripDrop.Days -> MoveTarget.ByDays(drop.days) + is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end) + }, + ), + ) + } + // A drafted block (#153) opens the new-event form at its own instants: the // end it is drawn to differs on a DST change (#387). val createDraft: (EventInstance) -> Unit = { draft -> @@ -661,6 +688,7 @@ private fun WeekContent( onCreateAt = onCreateAt, onDrop = onDrop, onResize = onResize, + onStripDrop = onStripDrop, ) Box(modifier = modifier) { @@ -736,7 +764,12 @@ private fun WeekPager( val loaded = pageState as? WeekUiState.Success // Until its days arrive a page shows their frame without events. val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) } - val stripHeight = loaded?.allDayStripHeight() + // A bar held over other days in the strip makes room there before it is dropped. + val held = (dragController.stripDrag ?: dragController.stripSettling) + ?.takeIf { page == pagerState.currentPage } + val zone = LocalTimelineZone.current + val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) } + val stripHeight = shown?.allDayStripHeight() DisposableEffect(page, stripHeight) { if (stripHeight != null) stripHeights[page] = stripHeight onDispose { stripHeights.remove(page) } @@ -744,8 +777,8 @@ private fun WeekPager( ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { WeekPage( // A cached page may predate midnight; today comes off the clock (#373). - state = remember(loaded, empty, today) { - loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty + state = remember(shown, empty, today) { + shown?.takeIf { it.today != today }?.copy(today = today) ?: shown ?: empty }, headerBias = kind.headerBias, allDayHeight = allDayHeight, @@ -757,6 +790,25 @@ private fun WeekPager( } } +/** + * [this] with the strip bar [held] sideways laid out at the days it is over, so + * the bars there move aside live. The bar keeps its own event, and with it its + * key and the gesture on it; one moved off the page keeps a lane it is not seen in. + */ +internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success { + if (held == null || held.overGrid || held.dayShift == 0) return this + val id = held.event.instanceId + val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this + val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event } + val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it } + return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0)) +} + +private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance { + val dates = dateZone(zone) + return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates)) +} + /** A page with no events yet, for one whose days are still loading. */ private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success { val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) } @@ -992,7 +1044,7 @@ private fun TimelinePageContent( LocalChipGround provides MaterialTheme.colorScheme.surface, ) { AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, - animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick) + animatedHeight = animatedAllDayHeight, actions = actions, active = active) } } Spacer(Modifier.height(TIMELINE_TOP_GAP)) @@ -1127,9 +1179,29 @@ private fun AllDayStrip( bars: List, height: Dp, animatedHeight: (() -> Dp)? = null, - onEventClick: (EventInstance) -> Unit, + actions: TimelineActions, + active: Boolean, ) { val dark = LocalFloretDarkTheme.current + val zone = LocalTimelineZone.current + val dragController = actions.dragController + // Only the page the finger is on draws the drag; the controller is shared. + val held = (dragController.stripDrag ?: dragController.stripSettling)?.takeIf { active } + val sideways = held?.takeIf { !it.overGrid } + val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false } + val settling = dragController.stripSettling?.takeIf { active } + // Seated once the re-read draws the event at the days it was dropped on. + LaunchedEffect(settling, bars) { + if (settling == null) return@LaunchedEffect + val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY) + val landed = bars.any { bar -> + bar.event.instanceId != settling.event.instanceId && + (bar.event.eventId == settling.event.eventId || + (settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) && + bar.event.spanFirstDay(zone) == target + } + if (landed) dragController.seatStrip() + } Row( modifier = Modifier @@ -1150,12 +1222,20 @@ private fun AllDayStrip( content = { bars.forEach { bar -> key(bar.event.eventId, bar.event.start) { + val moving = sideways?.event?.instanceId == bar.event.instanceId + val lane = animatedStripLane(bar.toLane) AllDayBar( event = bar.event, lastDay = bar.toRange.endInclusive, dark = dark, - onClick = { onEventClick(bar.event) }, + dragController = dragController, + lifted = moving, + ghosted = lifted == bar.event.instanceId, + onClick = { actions.onEventClick(bar.event) }, + onDrop = actions.onStripDrop, modifier = Modifier + .then(if (moving) Modifier.zIndex(1f) else Modifier) + .then(StripLane(lane)) .morphEvent(ViewMorphKey.event(bar.event, bar.keyDate)) .padding(horizontal = 1.dp, vertical = 1.dp), ) @@ -1172,10 +1252,19 @@ private fun AllDayStrip( val progress = geometry.progress() val placed = measurables.mapIndexed { i, measurable -> val bar = bars[i] - val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f) + val shift = sideways?.takeIf { it.event.instanceId == bar.event.instanceId }?.dayShift ?: 0 + val extent = if (shift == 0) { + geometry.extent(bar.fromRange, bar.toRange, width, 0f) + } else { + // The whole event, not the part this page shows: it may come in from off screen. + val moved = bar.event.spanFirstDay(zone)..bar.event.spanLastDay(zone) + geometry.extent(moved.shiftedBy(shift), moved.shiftedBy(shift), width, 0f) + } val left = extent.left.roundToInt() val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0) - val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt() + // Standing still a bar tweens to a new lane, as bars make room for a dragged one. + val lane = (measurable.parentData as? StripLane)?.lane?.takeIf { bar.fromLane == bar.toLane } + val y = ((lane?.value ?: lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress)) * rowPx).roundToInt() Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y) } layout(constraints.maxWidth, constraints.maxHeight) { @@ -1185,12 +1274,31 @@ private fun AllDayStrip( } } +private data class StripLane(val lane: State) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = this@StripLane +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun animatedStripLane(lane: Int): State { + val spec: FiniteAnimationSpec = + if (rememberReduceMotion()) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + return animateFloatAsState(lane.toFloat(), spec, label = "strip-lane") +} + +private fun ClosedRange.shiftedBy(days: Int): ClosedRange = + start.plus(days, DateTimeUnit.DAY)..endInclusive.plus(days, DateTimeUnit.DAY) + @Composable private fun AllDayBar( event: EventInstance, lastDay: LocalDate, dark: Boolean, + dragController: TimelineDragController, + lifted: Boolean, + ghosted: Boolean, onClick: () -> Unit, + onDrop: (StripDrop) -> Unit, modifier: Modifier = Modifier, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } @@ -1198,8 +1306,29 @@ private fun AllDayBar( // A bar is one piece, so it dims once the last day it covers here has passed. val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff) val paint = eventPaint(event, dark) + val zone = LocalTimelineZone.current + val draggable = eventDragAllowed(event) + // Long press lifts the bar: sideways moves its days, down into the grid makes it timed. + val dragModifier = rememberEventDragSource( + enabled = draggable, + key = event.instanceId, + onPickUp = { pointer, _, _ -> + dragController.beginStrip(event, zone, pointer) + if (dragController.stripDrag != null) PickUp.Drag else PickUp.None + }, + onMove = dragController::move, + onDrop = { dragController.finishStrip()?.let(onDrop) }, + onCancel = dragController::cancel, + onRelease = dragController::cancel, + ) + // Ghosted while it hangs over the grid as a timed block, and until that drop lands. + val ghost = ghostAlpha(ghosted) + val raise = selectionLift(lifted) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) + .alpha(ghost) + .selectionLift(raise, EventChipShape) + .then(dragModifier) .eventSurface(paint, EventChipShape) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 4bfa8cf..90015f3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.coversWholeDays import de.jeanlucmakiola.calendula.domain.displayPriority +import de.jeanlucmakiola.calendula.domain.displayStart import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.ui.common.PageStateCache @@ -207,7 +208,7 @@ abstract class PagedTimelineViewModel( ): WeekUiState { calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) } val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) } - val (allDay, timed) = instances.partition { it.coversWholeDays(zone) } + val (allDay, timed) = instances.splitWholeDays(zone) return WeekUiState.Success( weekStart = span.start, today = today, @@ -305,7 +306,8 @@ internal fun layoutAllDay( compareBy { it.priority } .thenBy { it.startCol } .thenBy { it.endCol } - .thenBy { it.event.start } + .thenBy { it.event.displayStart(zone) } + .thenBy { it.event.title } .thenBy { it.event.instanceId }, ) @@ -426,12 +428,19 @@ internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean { return coversDay(secondDay, zone) } +/** + * Split events into those for the all-day strip and those for the time grid: + * all-day events and timed ones from midnight to midnight go to the strip (#296). + */ +internal fun List.splitWholeDays(zone: TimeZone): Pair, List> = + partition { it.coversWholeDays(zone) } + /** * Clip [events] to a single [day] and assign lanes so overlapping events render * side-by-side. Lane count is computed per overlap-cluster (a maximal run of * chained-overlapping events), matching the common phone week-view behaviour. * - * All-day events are ignored here — they live in the all-day strip. + * Takes the timed half of [splitWholeDays]; the rest lives in the all-day strip. */ internal fun layoutDay( events: List, @@ -444,7 +453,6 @@ internal fun layoutDay( data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int) val raw = events.asSequence() - .filterNot { it.coversWholeDays(zone) } .mapNotNull { ev -> if (ev.start == ev.end) { // Instant event: keep only if the point falls inside this day. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index e103efb..fb3a362 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week import de.jeanlucmakiola.calendula.ui.common.startOfWeek -import de.jeanlucmakiola.calendula.domain.coversWholeDays import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -62,6 +61,7 @@ internal fun WeekViewPreview( onCreateAt = { _, _ -> }, onDrop = {}, onResize = {}, + onStripDrop = {}, ), ) } @@ -76,12 +76,12 @@ private fun sampleWeekState( ): WeekUiState.Success { val start = if (dayCount == 7) today.startOfWeek(weekStart) else today val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) } - val events = sampleTimelineEvents(days, today, zone) + val (allDay, timed) = sampleTimelineEvents(days, today, zone).splitWholeDays(zone) return WeekUiState.Success( weekStart = start, today = today, days = days, - allDaySpans = layoutAllDay(events.filter { it.coversWholeDays(zone) }, days, zone), - timedByDay = days.associateWith { layoutDay(events, it, zone) }, + allDaySpans = layoutAllDay(allDay, days, zone), + timedByDay = days.associateWith { layoutDay(timed, it, zone) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt index 6ed2d29..d7e377c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.split +import de.jeanlucmakiola.calendula.domain.isCrossedOut import de.jeanlucmakiola.calendula.widget.InAppLanguage import de.jeanlucmakiola.calendula.widget.WidgetKind import de.jeanlucmakiola.calendula.widget.previewSizeOf @@ -268,7 +269,9 @@ internal fun MonthDayWidgetContent( val day = selected ?: source.today val zone = systemZone() val days = monthGridDays(index, source.weekStart) - val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet() + // Declined and cancelled events are no plan for the day, as on the week strip (#388). + val planned = source.instances.filterNot { it.isCrossedOut } + val busy = groupAgendaDays(days.first(), days.last(), planned, zone).map { it.date }.toSet() Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) { Header(index, source.today) Spacer(GlanceModifier.height(4.dp)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt index a6720c2..cf856ca 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt @@ -1,6 +1,6 @@ package de.jeanlucmakiola.calendula.widget.week -import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.isCrossedOut import de.jeanlucmakiola.calendula.widget.InAppLanguage import de.jeanlucmakiola.calendula.widget.WidgetKind import de.jeanlucmakiola.calendula.widget.previewSizeOf @@ -146,8 +146,8 @@ private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) { else -> GlanceTheme.colors.onSurface } val locale = widgetLocale() - // A cancelled event is no plan for the day, so it adds no dot and no count (#388). - val events = data.eventsOn(date).filter { it.status != EventStatus.Cancelled } + // A declined or cancelled event is no plan for the day, so it adds no dot and no count (#388). + val events = data.eventsOn(date).filterNot { it.isCrossedOut } val colors = events.map { it.color }.distinct() val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate()) Column( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9baff1..723f091 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Calendula needs write access to move events That event no longer exists Can\'t move an event past the end of its series + A cancelled series can only move as a whole, and this would change which days it falls on Cancel OK diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 358ba57..a783baf 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -1147,6 +1147,18 @@ class EventWriteMapperTest { assertThat(values).containsEntry(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_TENTATIVE) } + @Test + fun `no new row is written cancelled`() { + val cancelled = form().copy(status = EventStatus.Cancelled) + assertThat(buildEventInsertValues(cancelled, "uid", cancelled.toWriteTimes(berlin))) + .doesNotContainKey(CalendarContract.Events.STATUS) + assertThat(buildOccurrenceExceptionValues(cancelled, originalInstanceMillis = 0L, zone = berlin)) + .containsEntry(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CONFIRMED) + val snapshot = masterSnapshot().copy(status = CalendarContract.Events.STATUS_CANCELED) + assertThat(buildMovedMasterValues(snapshot, targetCalendarId = 9L, uid = "u@calendula")) + .doesNotContainKey(CalendarContract.Events.STATUS) + } + // --- buildMovedMasterValues (calendar move: verbatim series copy) --- private fun masterSnapshot( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt index 3acd9f4..e84e658 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventColorOption +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import org.junit.jupiter.api.Test import kotlin.time.Instant @@ -31,6 +32,12 @@ class ImportedEventValuesTest { private fun values(event: ParsedIcsEvent, palette: List = emptyList()) = buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette) + @Test + fun `a cancelled event is imported confirmed`() { + assertThat(values(allDay().copy(status = EventStatus.Cancelled))[CalendarContract.Events.STATUS]) + .isEqualTo(CalendarContract.Events.STATUS_CONFIRMED) + } + @Test fun `a one-off carries DTEND and no recurrence`() { val v = values(allDay()) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt index c4bc0d9..3de4ab3 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt @@ -162,4 +162,13 @@ class InstanceMapperTest { assertThat(event.isCrossedOut).isTrue() assertThat(event.isOutlined).isFalse() } + + @Test + fun `a cancelled one-off from the events table gets a negated id`() { + val inst = reader(instanceId = 7L, eventId = 7L, status = CalendarContract.Events.STATUS_CANCELED) + .toCancelledOneOff() + assertThat(inst?.instanceId).isEqualTo(-7L) + assertThat(inst?.eventId).isEqualTo(7L) + assertThat(inst?.isCrossedOut).isTrue() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrderTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrderTest.kt new file mode 100644 index 0000000..56c9e34 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrderTest.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.testEvent +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class EventDisplayOrderTest { + + @Test + fun `whole-day events order by title east and west of UTC`() { + listOf("Europe/Berlin", "America/New_York").map(TimeZone::of).forEach { zone -> + val day = LocalDate(2026, 11, 5) + val allDay = testEvent( + id = 1, title = "B all-day", isAllDay = true, + start = Instant.parse("2026-11-05T00:00:00Z"), end = Instant.parse("2026-11-06T00:00:00Z"), + ) + val timed = testEvent( + id = 2, title = "A timed", + start = day.atStartOfDayIn(zone), end = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone), + ) + + assertThat(listOf(allDay, timed).sortedForDisplay(zone)).containsExactly(timed, allDay).inOrder() + assertThat(listOf(timed, allDay).sortedForDisplay(zone)).containsExactly(timed, allDay).inOrder() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index bcc1d29..83cce33 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -574,9 +574,18 @@ class EventFormTest { } @Test - fun `a cancelled form cannot be saved as a single occurrence`() { - assertThat(form().canSaveAsOccurrence()).isTrue() - assertThat(form().copy(status = EventStatus.Tentative).canSaveAsOccurrence()).isTrue() - assertThat(form().copy(status = EventStatus.Cancelled).canSaveAsOccurrence()).isFalse() + fun `a cancelled form saves only as the whole series`() { + assertThat(form().canSaveApartFromSeries()).isTrue() + assertThat(form().copy(status = EventStatus.Tentative).canSaveApartFromSeries()).isTrue() + assertThat(form().copy(status = EventStatus.Cancelled).canSaveApartFromSeries()).isFalse() + } + + @Test + fun `a copy of a cancelled event starts confirmed, a tentative one stays tentative`() { + val zone = TimeZone.of("Europe/Berlin") + assertThat(testDetail(status = EventStatus.Cancelled).toDuplicateForm(canModify = true, zone).status) + .isEqualTo(EventStatus.Confirmed) + assertThat(testDetail(status = EventStatus.Tentative).toDuplicateForm(canModify = true, zone).status) + .isEqualTo(EventStatus.Tentative) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt index 3bef4c9..91f0a17 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.calendula.domain +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.hours import com.google.common.truth.Truth.assertThat import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime @@ -195,11 +197,13 @@ class EventShiftTest { } @Test - fun `an all-day event ignores a resize`() { + fun `a resized all-day event becomes timed`() { val allDay = form(isAllDay = true) val start = allDay.start.dateTime.toInstant(berlin) - assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay) + val timed = allDay.resizedTo(start, start + 1.hours, berlin) + assertThat(timed.isAllDay).isFalse() + assertThat(timed.end.dateTime).isEqualTo(allDay.start.dateTime.toInstant(berlin).plus(1.hours).toLocalDateTime(berlin)) } // 02:00 CEST to 02:00 CET on 2026-10-25: one real hour, one wall-clock value. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index a70c742..e44e6f3 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -28,6 +28,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import java.nio.file.Path +import kotlinx.datetime.atStartOfDayIn +import kotlin.time.Duration.Companion.hours import kotlin.time.Instant /** @@ -676,7 +678,7 @@ class RescheduleViewModelTest { } @Test - fun `a cancelled series offers no single-occurrence move`( + fun `a cancelled series moves as a whole without asking`( @TempDir tempDir: Path, ) = runTest(dispatcher) { val fake = FakeCalendarDataSource().apply { @@ -686,12 +688,11 @@ class RescheduleViewModelTest { vm.move(oneHourLater()) advanceUntilIdle() - assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false, seriesOnly = true)) - vm.moveWithScope(RecurringWriteScope.ThisEvent) - advanceUntilIdle() + assertThat(vm.scopePrompt.value).isNull() + assertThat(fake.updatedEvents).hasSize(1) assertThat(fake.updatedOccurrences).isEmpty() - assertThat(vm.scopePrompt.value).isNotNull() + assertThat(fake.updatedFromOccurrences).isEmpty() } @Test @@ -707,8 +708,77 @@ class RescheduleViewModelTest { advanceUntilIdle() assertThat(vm.scopePrompt.value).isNull() + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries) assertThat(vm.abandoned.value).isEqualTo(1) assertThat(fake.updatedEvents).isEmpty() assertThat(fake.updatedOccurrences).isEmpty() } + + @Test + fun `an all-day event dropped into the time grid becomes timed`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val dayStart = monday.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { + testDetail( + instance = testEvent( + id = 42L, title = "Offsite", isAllDay = true, + start = Instant.fromEpochMilliseconds(dayStart), + end = Instant.fromEpochMilliseconds(dayStart + 86_400_000L), + ), + ) + } + } + val vm = viewModel(tempDir, fake) + val start = Instant.fromEpochMilliseconds(beginMillis) + + vm.move( + MoveRequest( + eventId = 42L, + beginMillis = dayStart, + endMillis = dayStart + 86_400_000L, + target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)), + ), + ) + advanceUntilIdle() + + val updated = fake.updatedEvents.single().third + assertThat(updated.isAllDay).isFalse() + assertThat(updated.start.dateTime).isEqualTo(LocalDateTime(monday, LocalTime(12, 0))) + assertThat(updated.end.dateTime).isEqualTo(LocalDateTime(monday, LocalTime(13, 0))) + } + + @Test + fun `a recurring all-day event dropped into the grid early on its own day can still move the series`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val dayStart = monday.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { + testDetail( + instance = testEvent( + id = 42L, title = "Offsite", isAllDay = true, + start = Instant.fromEpochMilliseconds(dayStart), + end = Instant.fromEpochMilliseconds(dayStart + 86_400_000L), + ), + rrule = "FREQ=WEEKLY;BYDAY=MO", + ) + } + } + val vm = viewModel(tempDir, fake) + val eight = LocalDateTime(monday, LocalTime(8, 0)).toInstant(TimeZone.currentSystemDefault()) + + vm.move( + MoveRequest( + eventId = 42L, + beginMillis = dayStart, + endMillis = dayStart + 86_400_000L, + target = MoveTarget.Span(eight, eight + 1.hours), + ), + ) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index 9efac83..0fc4980 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -1,7 +1,9 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.StripDrag import de.jeanlucmakiola.calendula.ui.common.startOfWeek import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.testEvent import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -126,9 +128,9 @@ class WeekLayoutTest { } @Test - fun `all-day events are excluded from the timed layout`() { + fun `all-day events are kept out of the timed layout`() { val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true) - assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty() + assertThat(listOf(ev).splitWholeDays(zone)).isEqualTo(listOf(ev) to emptyList()) } @Test @@ -305,6 +307,25 @@ class WeekLayoutTest { fun `a timed event from midnight to midnight stays out of the time grid`() { val ev = testEvent(start = at(wed, 0), end = at(wed.plus(1, DateTimeUnit.DAY), 0)) - assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty() + assertThat(listOf(ev).splitWholeDays(zone)).isEqualTo(listOf(ev) to emptyList()) + } + + @Test + fun `a bar held over another day makes room there`() { + val held = event(at(mon, 0), at(mon.plusDays(1), 0), allDay = true, id = 1, title = "A") + val there = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true, id = 2, title = "B") + val state = WeekUiState.Success( + weekStart = mon, today = mon, days = weekDays, + allDaySpans = layoutAllDay(listOf(held, there), weekDays, zone), timedByDay = emptyMap(), + ) + assertThat(state.allDaySpans.map { it.lane }).containsExactly(0, 0) + + val preview = state.withStripPreview(StripDrag(held, dayShift = 2), zone).allDaySpans + val moved = preview.single { it.event.instanceId == held.instanceId } + + assertThat(moved.event).isEqualTo(held) + assertThat(moved.startCol).isEqualTo(2) + assertThat(preview.map { it.lane }).containsExactly(0, 1) + assertThat(state.withStripPreview(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state) } } diff --git a/floret-kit b/floret-kit index ac73a0c..1ff6702 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit ac73a0c85acdf3f72ca18a17baec84b6407d8486 +Subproject commit 1ff6702fe6ff1ab695157e6559ec3761ba9f01b9