Compare commits

...
Author SHA1 Message Date
Jean-Luc Makiola edd4bf965a Follow-ups for whole-day events and event status (#296, #388) (#484)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Drag all-day strip events between days or back into the grid, show cancelled one-off events, review fixes, floret-kit pinned to main.
2026-11-06 10:13:03 +01:00
Jean-Luc Makiolaandmakiolaj 90245b6893 Midnight-to-midnight events as all-day, and event status (#296, #388) (#472)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Combines #470 and #471 into one PR.

## Midnight-to-midnight events (#296)

A timed event that starts and ends on local midnight now displays as an all-day event:

- Day/week: in the all-day strip instead of the time grid.
- Month and month widget: a bar with no start time.
- Agenda and agenda widget: "All day".
- Sorting: with the all-day events.
- Countdown widget: skipped in next-event mode like other all-day events, with the all-day label when pinned.
- Reminder notification: the text names the day(s) instead of 00:00 – 00:00.

The event itself stays timed. The detail sheet still shows 00:00 – 00:00, and reminders still fire at their own lead time before 00:00, since that is what the stored reminder says. Midnight is checked in the device zone, so a feed event stored as 22:00Z only counts as whole-day at UTC+2. Search results are left as they are.

Dragging or resizing a block in the timeline so it lands exactly on midnight to midnight moves it to the all-day strip. The floating copy and the selection are dropped right away. Display priority is now computed once per event, not on every sort comparison.

## Event status (#388)

The calendar views now use the event's own status (Events.STATUS), not just your reply to an invite:

- Cancelled events are struck through and sorted last, the same as declined invites. This applies to day/week, month, agenda, search and the widgets. The countdown widget skips them in both modes, and the week-strip widget leaves them out of its count and dots.
- Tentative events get the outline used for unanswered invites (in-app grids). An event that is both cancelled and an open invite is struck through, not outlined.
- Cancelled standalone events and cancelled series are no longer hidden. Cancelled exception rows (a single occurrence deleted from a series) are still filtered out, matched by ORIGINAL_ID or ORIGINAL_SYNC_ID.
- The editor has a new optional Status field after Availability, built on floret-kit's new `SegmentedChoiceCard` (Availability uses it too). A new event writes STATUS only when it isn't Confirmed, an edit writes it only when it changed.
- `isDeclined` was renamed to `isCrossedOut`, and a new `isOutlined` covers both cases.

Deviations from the issue:

- The editor offers Confirmed and Tentative only. Cancelled is offered only for an event that's already cancelled, so it can be put back. Google treats a cancelled event as deleted, so letting people pick it would silently delete Google events.
- A cancelled series can't be edited or moved one occurrence at a time. "Only this event" is left out of the scope prompts, because Android saves a cancelled single occurrence as a deleted one. A drag that could only move the single occurrence is refused.
- The widgets don't draw outlines for unanswered invites either, so tentative events look filled there.

Depends on jlmakiola/floret-kit#27. The submodule is pinned to its branch, so that PR has to land on floret-kit main and the pin has to be moved before release.

Closes #296
Closes #388

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/472
2026-11-03 14:13:35 +01:00
54 changed files with 1044 additions and 149 deletions

No files matched your search

@@ -567,8 +567,31 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
queryInstances(beginMillis, endMillis, null, null)
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
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<EventInstance> {
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<EventInstance> =
// The series' own rows plus its exceptions, so an occurrence edited on
@@ -587,14 +610,12 @@ class AndroidCalendarDataSource @Inject constructor(
args: Array<String>?,
): List<EventInstance> {
ensureObserversRegistered()
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). 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}) AND " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED} OR " +
"(${CalendarContract.Instances.ORIGINAL_ID} IS NULL AND " +
"${CalendarContract.Instances.ORIGINAL_SYNC_ID} IS NULL)) AND " +
"${CalendarContract.Events.DELETED} = 0"
return resolver.query(
instancesUri(beginMillis, endMillis),
@@ -624,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,
@@ -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,6 +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())
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() }
@@ -179,6 +182,9 @@ internal fun buildEventUpdateValues(
if (updated.accessLevel != original.accessLevel) {
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
}
if (updated.status != original.status) {
put(CalendarContract.Events.STATUS, updated.status.toProviderStatus())
}
if (updated.colorKey != original.colorKey || updated.color != original.color) {
putAll(eventColorColumns(updated.colorKey, updated.color))
}
@@ -279,6 +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.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))
@@ -354,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 })
}
@@ -858,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() }
@@ -38,9 +38,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
color = color,
location = getString(InstanceProjection.IDX_LOCATION),
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
status = eventStatus(InstanceProjection.IDX_STATUS),
)
}
/**
* 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 —
@@ -62,6 +62,7 @@ internal object InstanceProjection {
CalendarContract.Instances.CALENDAR_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
CalendarContract.Instances.STATUS,
)
const val IDX_INSTANCE_ID = 0
@@ -75,6 +76,29 @@ internal object InstanceProjection {
const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9
const val IDX_SELF_ATTENDEE_STATUS = 10
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<String> = 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 {
@@ -198,6 +222,7 @@ internal object SearchProjection {
// Excerpted, not just filtered on: a hit has to show what it matched.
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
CalendarContract.Events.STATUS,
)
const val IDX_ID = 0
@@ -214,6 +239,7 @@ internal object SearchProjection {
const val IDX_RDATE = 11
const val IDX_DESCRIPTION = 12
const val IDX_SELF_ATTENDEE_STATUS = 13
const val IDX_STATUS = 14
}
/**
@@ -35,5 +35,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
status = eventStatus(SearchProjection.IDX_STATUS),
)
}
@@ -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
@@ -32,7 +35,8 @@ import java.util.Locale
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
*
* All-day instances store UTC midnights with an exclusive end, so they are read
* in UTC and the end day is the last *covered* day (issue #308).
* in UTC and the end day is the last *covered* day (issue #308). A timed event
* from local midnight to local midnight reads like one too, in [zone] (#296).
*/
fun reminderTimeText(
beginMillis: Long,
@@ -47,11 +51,17 @@ fun reminderTimeText(
tomorrowLabel: String,
yesterdayLabel: String,
): String {
if (isAllDay) {
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)
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate()
val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
val start = relativeDayPrefix(
@@ -1,18 +1,35 @@
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 {
isDeclined -> 3
isCrossedOut -> 3
spansMultipleDays(zone) -> 0
isAllDay -> 1
coversWholeDays(zone) -> 1
else -> 2
}
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
/**
* Shared order for agenda and split day lists. Grid bars add their lane geometry.
* Each event's priority is worked out once, not on every comparison.
*/
fun List<EventInstance>.sortedForDisplay(zone: TimeZone): List<EventInstance> =
map { Triple(it, it.displayPriority(zone), it.displayStart(zone)) }
.sortedWith(
compareBy<Triple<EventInstance, Int, Instant>> { 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
@@ -42,6 +42,8 @@ data class EventForm(
val reminders: List<Int> = emptyList(),
val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default,
/** Confirmed / Tentative / Cancelled (#388). */
val status: EventStatus = EventStatus.Confirmed,
/**
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
* means a one-off event. May hold rules the simple picker can't express —
@@ -113,6 +115,7 @@ enum class EventFormField {
Reminders,
Recurrence,
Availability,
Status,
Visibility,
Color,
Attendees,
@@ -178,6 +181,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
reminders = reminders.map { it.minutes }.distinct().sorted(),
availability = availability,
accessLevel = accessLevel,
status = status,
rrule = rrule.normalizedRrule(),
// The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its
@@ -213,7 +217,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
* re-deriving with the same occurrence would mask an externally moved
* event. Guests are covered (the form writes editable attendees), so an
* external attendee change now also trips the conflict check. Still not
* covered: status, the user's own response, reminder methods, the
* covered: the user's own response, reminder methods, the
* organizer/resource rows, and a recurring event's duration.
*/
data class EditSnapshot(
@@ -243,6 +247,22 @@ internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<F
/** The bare RRULE value the form edits; blank means none. */
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
/**
* Whether this form may be saved apart from its series, as one modified
* occurrence or as a split-off "this & following" tail. A cancelled exception
* row is how Android records a deleted occurrence, and Google reads an inserted
* cancelled event as deleted, so either would vanish rather than show struck
* through (#388).
*/
fun EventForm.canSaveApartFromSeries(): Boolean = status != EventStatus.Cancelled
/**
* The status a newly inserted row may carry. Google reads an inserted cancelled
* event as deleted, so every new row (a copy, an import, a calendar move or an
* exception) starts confirmed instead (#388).
*/
fun EventStatus.forNewEvent(): EventStatus = if (this == EventStatus.Cancelled) EventStatus.Confirmed else this
/**
* 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
@@ -269,6 +289,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability)
if (status != EventStatus.Confirmed) add(EventFormField.Status)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
if (colorKey != null || color != null) add(EventFormField.Color)
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
@@ -311,4 +332,5 @@ fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
status = status.forNewEvent(),
)
@@ -173,8 +173,11 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
}
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
/**
* The form with new start and end, each in the form's own zone — a resize. An
* all-day event becomes timed: one dragged out of the all-day strip.
*/
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
val timed = copy(isAllDay = false)
return timed.withTimes(newStart, newEnd, timed.resolvedZone(deviceZone))
}
@@ -73,6 +73,8 @@ data class EventInstance(
* (#180, #230).
*/
val response: EventResponse = EventResponse.Going,
/** The event's own Confirmed / Tentative / Cancelled (`Events.STATUS`, #388). */
val status: EventStatus = EventStatus.Confirmed,
)
/**
@@ -95,8 +97,19 @@ enum class EventResponse {
Declined,
}
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
/**
* Struck through on every surface and sorted last: an invitation you declined
* (#180), or an event its organizer cancelled (#388). Neither plans reminders.
*/
val EventInstance.isCrossedOut: Boolean
get() = response == EventResponse.Declined || status == EventStatus.Cancelled
/**
* Drawn as an outline, still undecided: an invitation you have not answered
* (#230), or a tentative event (#388).
*/
val EventInstance.isOutlined: Boolean
get() = !isCrossedOut && (response == EventResponse.Invited || status == EventStatus.Tentative)
/**
* Whether this event has finished relative to [now] — its end is at or before
@@ -137,6 +150,20 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
/**
* Whether this event fills whole days in [zone], so the grids and lists lay it
* out with the all-day events: an all-day event, or a timed one running from
* local midnight to local midnight (#296). The event itself stays timed.
*/
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)
data class EventDetail(
val instance: EventInstance,
val description: String?,
@@ -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,6 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
description = description.orEmpty(),
reminders = semanticReminderMinutes(),
availability = availability,
status = status.forNewEvent(),
rrule = recurrenceRule.normalizedRrule(),
)
}
@@ -32,7 +32,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
@@ -138,7 +138,7 @@ internal fun AgendaEventRow(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = declinedTitle(title, event.isDeclined),
title = declinedTitle(title, event.isCrossedOut),
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
position = position,
minHeight = 64.dp,
@@ -1,7 +1,8 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -18,7 +19,7 @@ import kotlin.time.Instant
* surface only formats the instants into its own locale/24h string.
*/
sealed interface AgendaTimeLabel {
/** An all-day event, or a whole in-between day of a multi-day span. */
/** An event filling whole days (#296), or a whole in-between day of a multi-day span. */
data object AllDay : AgendaTimeLabel
/** The first day of a multi-day timed event: when it begins. */
data class Starts(val start: Instant) : AgendaTimeLabel
@@ -30,7 +31,7 @@ sealed interface AgendaTimeLabel {
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
if (event.isAllDay) return AgendaTimeLabel.AllDay
if (event.coversWholeDays(zone)) return AgendaTimeLabel.AllDay
val firstDay = event.spanFirstDay(zone)
val lastDay = event.spanLastDay(zone)
return when {
@@ -87,7 +88,7 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
events = dayEvents.sortedForDisplay(zone),
)
}
}
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -29,6 +30,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Status -> R.string.event_edit_status
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
@@ -41,6 +43,7 @@ fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Status -> Icons.Default.TaskAlt
EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette
EventFormField.Attendees -> Icons.Default.People
@@ -91,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)
}
@@ -21,8 +21,8 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.domain.isOutlined
/** Stroke an invitation you have not answered is outlined with. */
val EVENT_OUTLINE_WIDTH = 1.dp
@@ -60,13 +60,13 @@ fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts
/**
* How one event's chip or block is painted, which turns on your answer to its
* invitation (#180, #230).
* invitation (#180, #230) and on the event's own status (#388).
*
* An invitation you have not answered is drawn as an outline: its calendar's
* colour on the border and on the title, over the surface the chip sits on. So
* it holds a chip's shape and a chip's weight without claiming the filled
* container an event you are going to gets. A declined one keeps the fill and
* its strike-through.
* An invitation you have not answered, or a tentative event, is drawn as an
* outline: its calendar's colour on the border and on the title, over the
* surface the chip sits on. So it holds a chip's shape and a chip's weight
* without claiming the filled container an event you are going to gets. A
* declined or cancelled one keeps the fill and its strike-through.
*/
@Immutable
data class EventPaint(
@@ -87,7 +87,7 @@ data class EventPaint(
@Composable
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
val soften = LocalSoftenColors.current
if (event.response == EventResponse.Invited) {
if (event.isOutlined) {
// Harmonised even when the setting is off. Raw mode exists so a filled
// container matches what the sync source paints, and an outlined chip
// has no container — what it has is coloured *text*, which needs the
@@ -117,7 +117,7 @@ fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
outline = null,
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
decoration = declinedDecoration(event.isDeclined),
decoration = declinedDecoration(event.isCrossedOut),
titleWeight = null,
)
}
@@ -41,19 +41,19 @@ const val EventDimAlpha = 0.4f
* is still something the organiser expects you at. Null for everything else, so
* it drops straight into a `Text`'s `textDecoration`.
*/
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
if (isDeclined) TextDecoration.LineThrough else null
fun declinedDecoration(isCrossedOut: Boolean): TextDecoration? =
if (isCrossedOut) TextDecoration.LineThrough else null
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
fun declinedTitle(title: String, isCrossedOut: Boolean): AnnotatedString = if (isCrossedOut) {
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
} else {
AnnotatedString(title)
}
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
if (!isDeclined) {
fun declinedTitle(title: AnnotatedString, isCrossedOut: Boolean): AnnotatedString =
if (!isCrossedOut) {
title
} else {
buildAnnotatedString {
@@ -6,6 +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.canSaveApartFromSeries
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem
@@ -57,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
}
@@ -114,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
}
@@ -203,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
@@ -227,15 +240,21 @@ class RescheduleViewModel @Inject constructor(
busy = false
return@launch
}
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) {
_outcome.value = MoveOutcome.BlockedCancelledSeries
_abandoned.value += 1
busy = false
} else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
resize = request.target is MoveTarget.Span,
resize = prepared.isResize,
)
}
}
@@ -459,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
}
@@ -494,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()
}
@@ -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<LocalDate, Int, Int> 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,12 +859,16 @@ 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).
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
landed.eventSpanMin % MINUTES_PER_DAY == 0
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
// [noteGrid] can never seat it and it would sit at the top of today for
// the whole settle timeout, long after the event moved off-screen.
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
@@ -928,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
@@ -1013,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
@@ -1177,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) {
@@ -1201,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()
@@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.components.SegmentedChoiceCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
@@ -119,6 +120,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
@@ -892,6 +894,15 @@ private fun EventEditContent(
AvailabilityCard(availability = form.availability, onSelect = viewModel::setAvailability)
}
OptionalFormSection(visible = EventFormField.Status in state.visibleFields) {
Spacer(Modifier.height(gap))
StatusCard(
status = form.status,
offerCancelled = state.offerCancelled,
onSelect = viewModel::setStatus,
)
}
OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) {
Spacer(Modifier.height(gap))
IconInfoCard(
@@ -1314,33 +1325,33 @@ private fun RecurrenceCard(state: EventEditUiState, locale: Locale, onClick: (()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) {
IconInfoCard(
SegmentedChoiceCard(
icon = Icons.Default.EventAvailable,
iconContentDescription = null,
header = {
Text(
text = stringResource(R.string.event_edit_availability),
style = MaterialTheme.typography.titleMedium,
)
},
) {
Spacer(Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(
selected = availability == Availability.Busy,
onClick = { onSelect(Availability.Busy) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
) { Text(stringResource(R.string.event_availability_busy)) }
SegmentedButton(
selected = availability == Availability.Free,
onClick = { onSelect(Availability.Free) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
) { Text(stringResource(R.string.event_availability_free)) }
}
}
title = stringResource(R.string.event_edit_availability),
options = listOf(
Availability.Busy to stringResource(R.string.event_availability_busy),
Availability.Free to stringResource(R.string.event_availability_free),
),
selected = availability,
onSelect = onSelect,
)
}
@Composable
private fun StatusCard(status: EventStatus, offerCancelled: Boolean, onSelect: (EventStatus) -> Unit) {
SegmentedChoiceCard(
icon = eventFormFieldIcon(EventFormField.Status),
title = stringResource(R.string.event_edit_status),
options = listOfNotNull(
EventStatus.Confirmed to stringResource(R.string.event_status_confirmed),
EventStatus.Tentative to stringResource(R.string.event_status_tentative),
(EventStatus.Cancelled to stringResource(R.string.event_status_cancelled)).takeIf { offerCancelled },
),
selected = status,
onSelect = onSelect,
)
}
/**
@@ -48,6 +48,12 @@ data class EventEditUiState(
* then drops "only this event" (an exception row can't carry a rule).
*/
val recurrenceChanged: Boolean = false,
/**
* Whether the Status field offers Cancelled: only for an event that already
* is. Google reads a cancelled event as deleted, so picking it is no safe
* way to strike an event through (#388).
*/
val offerCancelled: Boolean = false,
/**
* The event-colour palette the resolved target calendar publishes; empty
* when it exposes none. Non-empty → the colour picker offers these swatches
@@ -14,8 +14,10 @@ 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.canSaveApartFromSeries
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee
@@ -335,6 +337,7 @@ class EventEditViewModel @Inject constructor(
// the scope dialog drops "only this event" after a rule change.
recurrenceChanged = local.editTarget != null &&
resolved.rrule != local.editTarget.original.rrule,
offerCancelled = local.editTarget?.original?.status == EventStatus.Cancelled,
colorPalette = palette,
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
)
@@ -671,6 +674,7 @@ class EventEditViewModel @Inject constructor(
applyDefaultDuration(id)
}
fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
fun setStatus(value: EventStatus) = edit { it.copy(status = value) }
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
@@ -766,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
}
@@ -4,6 +4,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone
@@ -11,8 +12,8 @@ import java.util.Locale
/**
* The start time a chip shows before its title (#219), or null when it has none
* to show: an all-day chip never carries a time, and neither does a bar carried
* in from an earlier week, whose start is not in this segment.
* to show: a chip filling whole days never carries a time (#296), and neither
* does a bar carried in from an earlier week, whose start is not in this segment.
*/
internal fun monthChipTime(
event: EventInstance,
@@ -21,7 +22,7 @@ internal fun monthChipTime(
is24Hour: Boolean,
locale: Locale,
): String? {
if (event.isAllDay || continuesLeft) return null
if (event.coversWholeDays(zone) || continuesLeft) return null
return formatTimeOfDay(event.start, zone, is24Hour, locale)
}
@@ -13,14 +13,15 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -491,7 +492,7 @@ internal fun layoutCalendarWeek(
// crossing midnight into the next week row covers a single day in each, and
// counting per row drew it as two unrelated pills (#253).
val (bars, singles) = weekEvents.partition { ev ->
ev.isAllDay || ev.spansMultipleDays(zone)
ev.coversWholeDays(zone) || ev.spansMultipleDays(zone)
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(
@@ -510,7 +511,7 @@ internal fun layoutCalendarWeek(
// Declined last, so a day that overflows drops the events you said
// no to before the ones you are actually going to (#230).
singles.filter { it.coversDay(d, zone) }
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
.sortedWith(compareBy<EventInstance> { it.isCrossedOut }.thenBy { it.start })
},
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
)
@@ -531,7 +532,7 @@ internal fun instancesByDay(
days.associateWith { day ->
instances
.filter { it.coversDay(day, zone) }
.sortedWith(eventDisplayComparator(zone))
.sortedForDisplay(zone)
}
/**
@@ -75,7 +75,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
@@ -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
@@ -499,7 +500,7 @@ private fun SearchResultRow(
// Faded like a past event anywhere else in the app — search reaches back
// through the whole history.
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isCrossedOut),
summary = searchSummary(hit, highlight),
position = position,
minHeight = 64.dp,
@@ -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)
@@ -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) },
)
}
}
@@ -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<PlacedBar>,
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<Float>) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@StripLane
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun animatedStripLane(lane: Int): State<Float> {
val spec: FiniteAnimationSpec<Float> =
if (rememberReduceMotion()) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
return animateFloatAsState(lane.toFloat(), spec, label = "strip-lane")
}
private fun ClosedRange<LocalDate>.shiftedBy(days: Int): ClosedRange<LocalDate> =
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)
@@ -13,7 +13,9 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
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
@@ -206,8 +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 = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
val (allDay, timed) = instances.splitWholeDays(zone)
return WeekUiState.Success(
weekStart = span.start,
today = today,
@@ -292,20 +293,21 @@ internal fun layoutAllDay(
days: List<LocalDate>,
zone: TimeZone,
): List<AllDaySpan> {
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int)
val raw = events
.mapNotNull { ev ->
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last(), ev.displayPriority(zone))
}
// Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230).
.sortedWith(
compareBy<Raw> { it.event.displayPriority(zone) }
compareBy<Raw> { 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<EventInstance>.splitWholeDays(zone: TimeZone): Pair<List<EventInstance>, List<EventInstance>> =
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<EventInstance>,
@@ -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.isAllDay }
.mapNotNull { ev ->
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
@@ -61,6 +61,7 @@ internal fun WeekViewPreview(
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
onStripDrop = {},
),
)
}
@@ -75,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.isAllDay }, days, zone),
timedByDay = days.associateWith { layoutDay(events, it, zone) },
allDaySpans = layoutAllDay(allDay, days, zone),
timedByDay = days.associateWith { layoutDay(timed, it, zone) },
)
}
@@ -21,7 +21,7 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.eventFill
@@ -112,7 +112,7 @@ internal fun WidgetEventRow(
color = titleInk,
fontSize = metrics.title,
fontWeight = FontWeight.Medium,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
),
)
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
@@ -42,8 +42,8 @@ internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMo
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
* reads the same on the home screen as it does inside the app (#180).
*/
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None
fun glanceDeclinedDecoration(isCrossedOut: Boolean): TextDecoration =
if (isCrossedOut) TextDecoration.LineThrough else TextDecoration.None
/** Static, so a language change recomposes the whole widget rather than letting unchanged parts skip. */
private val LocalWidgetLanguage = staticCompositionLocalOf<String?> { null }
@@ -6,7 +6,7 @@ import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import androidx.compose.runtime.Composable
@@ -441,7 +441,7 @@ private fun EventRow(
color = titleInk,
fontSize = metrics.eventTitle,
fontWeight = FontWeight.Medium,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
),
)
Text(
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
@@ -74,17 +76,18 @@ fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant
}
/**
* The next-event mode's pick: timed, not declined, not over; of those, the one
* whose countdown changes soonest — a running event's end, else a start.
* The next-event mode's pick: timed and not filling whole days (#296), not
* declined or cancelled, not over; of those, the one whose countdown changes
* soonest — a running event's end, else a start.
*/
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
fun pickNextEvent(instances: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
instances
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
.filter { !it.coversWholeDays(zone) && !it.isCrossedOut && it.end > now }
.minByOrNull { target(it, now) }
/** A pinned event's occurrence to count to: the running or next one. */
/** A pinned event's occurrence to count to: the running or next one that is not cancelled (#388). */
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
occurrences.filter { it.status != EventStatus.Cancelled && it.counted(zone).end > now }.minByOrNull { it.start }
/**
* The span a countdown counts over. An all-day event is stored on UTC
@@ -75,7 +75,11 @@ internal suspend fun Context.loadCountdown(
val repository = widgetEntryPoint().calendarRepository()
val event = when (options.mode) {
CountdownMode.NEXT ->
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
pickNextEvent(
repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars),
now,
systemZone(),
)
CountdownMode.PINNED -> options.eventId?.let { id ->
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
}
@@ -40,6 +40,7 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
@@ -210,12 +211,13 @@ private fun whenText(context: Context, event: EventInstance, data: CountdownData
val at = if (running) counted.end else counted.start
val date = at.toLocalDateTime(systemZone()).date
val day = relativeDayLabel(context, date, data.today)
val wholeDays = event.coversWholeDays(systemZone())
return when {
event.isAllDay && running -> context.getString(
wholeDays && running -> context.getString(
R.string.widget_countdown_until,
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
)
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
wholeDays -> "$day · ${context.getString(R.string.event_detail_all_day)}"
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
}
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.Color
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
@@ -129,7 +129,7 @@ private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boole
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
views.setTextViewText(R.id.bar_title, if (event.isCrossedOut) span(title, StrikethroughSpan()) else title)
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
return views
}
@@ -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))
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.week
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
@@ -145,7 +146,9 @@ private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
else -> GlanceTheme.colors.onSurface
}
val locale = widgetLocale()
val colors = data.eventsOn(date).map { it.color }.distinct()
// 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(
modifier = GlanceModifier
@@ -155,7 +158,7 @@ private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week)))
.semantics {
contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString(
R.plurals.widget_day_with_events, data.eventsOn(date).size, label, data.eventsOn(date).size,
R.plurals.widget_day_with_events, events.size, label, events.size,
)
},
horizontalAlignment = Alignment.CenterHorizontally,
+3
View File
@@ -111,6 +111,7 @@
<string name="event_move_write_denied">Calendula needs write access to move events</string>
<string name="event_move_gone">That event no longer exists</string>
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
<string name="event_move_blocked_cancelled_series">A cancelled series can only move as a whole, and this would change which days it falls on</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_ok">OK</string>
@@ -154,6 +155,7 @@
<string name="reminder_unit_days">days</string>
<string name="reminder_unit_weeks">weeks</string>
<string name="event_edit_availability">Availability</string>
<string name="event_edit_status">Status</string>
<string name="event_edit_visibility">Visibility</string>
<!-- Event form — time zone -->
@@ -252,6 +254,7 @@
<!-- Event detail — full read (v0.6) -->
<string name="event_detail_reminders">Reminders</string>
<string name="event_detail_timezone">Time zone</string>
<string name="event_status_confirmed">Confirmed</string>
<string name="event_status_tentative">Tentative</string>
<string name="event_status_cancelled">Cancelled</string>
<string name="event_availability_free">Free</string>
@@ -4,6 +4,7 @@ import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.realignRecurrence
@@ -1112,6 +1113,52 @@ class EventWriteMapperTest {
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
}
@Test
fun `a status change on update writes just the status`() {
val original = form()
val values = update(original, original.copy(status = EventStatus.Cancelled))
assertThat(values).containsExactly(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
@Test
fun `an unchanged status writes no status column`() {
val original = form().copy(status = EventStatus.Tentative)
assertThat(update(original, original.copy(title = "Renamed")))
.doesNotContainKey(CalendarContract.Events.STATUS)
}
@Test
fun `a new event writes its status only when it is not confirmed`() {
val confirmed = form()
assertThat(buildEventInsertValues(confirmed, "uid", confirmed.toWriteTimes(berlin)))
.doesNotContainKey(CalendarContract.Events.STATUS)
val tentative = form().copy(status = EventStatus.Tentative)
assertThat(buildEventInsertValues(tentative, "uid", tentative.toWriteTimes(berlin)))
.containsEntry(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_TENTATIVE)
}
@Test
fun `occurrence exception writes the form's status`() {
val values = buildOccurrenceExceptionValues(
form = form().copy(status = EventStatus.Tentative),
originalInstanceMillis = 0L,
zone = berlin,
)
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(
@@ -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<EventColorOption> = 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())
@@ -3,7 +3,9 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.domain.isOutlined
import kotlin.time.Instant
import org.junit.jupiter.api.Test
@@ -21,6 +23,7 @@ class InstanceMapperTest {
calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null,
selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE,
status: Int? = null,
): MapColumnReader = MapColumnReader(
InstanceProjection.IDX_INSTANCE_ID to instanceId,
InstanceProjection.IDX_EVENT_ID to eventId,
@@ -33,6 +36,7 @@ class InstanceMapperTest {
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
InstanceProjection.IDX_LOCATION to location,
InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus,
InstanceProjection.IDX_STATUS to status,
)
@Test
@@ -98,17 +102,17 @@ class InstanceMapperTest {
@Test
fun `a declined invitation is marked, any other answer is not`() {
assertThat(reader().toEventInstance()!!.isDeclined).isFalse()
assertThat(reader().toEventInstance()!!.isCrossedOut).isFalse()
assertThat(
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED)
.toEventInstance()!!.isDeclined,
.toEventInstance()!!.isCrossedOut,
).isTrue()
listOf(
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED,
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE,
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
).forEach { status ->
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isCrossedOut).isFalse()
}
}
@@ -129,4 +133,42 @@ class InstanceMapperTest {
.isEqualTo(expected)
}
}
@Test
fun `the event status is read, a missing one is confirmed`() {
assertThat(reader().toEventInstance()!!.status).isEqualTo(EventStatus.Confirmed)
assertThat(reader(status = CalendarContract.Events.STATUS_TENTATIVE).toEventInstance()!!.status)
.isEqualTo(EventStatus.Tentative)
assertThat(reader(status = CalendarContract.Events.STATUS_CANCELED).toEventInstance()!!.status)
.isEqualTo(EventStatus.Cancelled)
}
@Test
fun `a cancelled event is crossed out, a tentative one outlined`() {
val cancelled = reader(status = CalendarContract.Events.STATUS_CANCELED).toEventInstance()!!
val tentative = reader(status = CalendarContract.Events.STATUS_TENTATIVE).toEventInstance()!!
assertThat(cancelled.isCrossedOut).isTrue()
assertThat(cancelled.isOutlined).isFalse()
assertThat(tentative.isCrossedOut).isFalse()
assertThat(tentative.isOutlined).isTrue()
}
@Test
fun `a cancelled unanswered invitation is crossed out rather than outlined`() {
val event = reader(
selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
status = CalendarContract.Events.STATUS_CANCELED,
).toEventInstance()!!
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()
}
}
@@ -212,4 +212,30 @@ class ReminderTimeTextTest {
)
assertThat(text).isEqualTo("Today")
}
@Test
fun `a timed event from local midnight to midnight reads like an all-day one`() {
val oneDay = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
today = LocalDate.of(2026, 6, 11),
)
assertThat(oneDay).isEqualTo("Tomorrow")
val twoDays = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
)
assertThat(twoDays).startsWith("Today – ")
}
@Test
fun `midnight is read in the device zone`() {
// Midnight in Berlin is 22:00 the day before in UTC, so it is timed there.
val text = text(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
zone = ZoneOffset.UTC,
)
assertThat(text).contains("22:00")
}
}
@@ -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()
}
}
}
@@ -346,6 +346,7 @@ class EventFormTest {
rrule = "FREQ=DAILY",
availability = Availability.Free,
accessLevel = AccessLevel.Private,
status = EventStatus.Tentative,
color = 0xFFD50000.toInt(),
)
assertThat(full.populatedFields()).containsExactly(
@@ -354,6 +355,7 @@ class EventFormTest {
EventFormField.Reminders,
EventFormField.Recurrence,
EventFormField.Availability,
EventFormField.Status,
EventFormField.Visibility,
EventFormField.Color,
)
@@ -570,4 +572,20 @@ class EventFormTest {
val started = form.withStart(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin)
assertThat(started.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
@Test
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)
}
}
@@ -82,4 +82,39 @@ class EventInstanceSpanTest {
val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false)
assertThat(timed.dateZone(newYork)).isEqualTo(newYork)
}
@Test
fun `a timed event from local midnight to local midnight covers whole days`() {
// Stored as 22:00Z, the shape ICSx5 gives a date-only feed event (#296).
val oneDay = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
val twoDays = instance(utc(2026, 7, 18, 22), utc(2026, 7, 20, 22), isAllDay = false)
assertThat(oneDay.coversWholeDays(berlin)).isTrue()
assertThat(twoDays.coversWholeDays(berlin)).isTrue()
}
@Test
fun `local midnight is read in the device zone`() {
val event = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
assertThat(event.coversWholeDays(newYork)).isFalse()
}
@Test
fun `a timed event off midnight at either end does not cover whole days`() {
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19, 23), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
assertThat(instance(utc(2026, 7, 19, 1), utc(2026, 7, 20), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
}
@Test
fun `a zero-length event at midnight does not cover whole days`() {
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19), isAllDay = false).coversWholeDays(TimeZone.UTC))
.isFalse()
}
@Test
fun `an all-day event covers whole days in any zone`() {
assertThat(allDayJul19().coversWholeDays(newYork)).isTrue()
assertThat(allDayJul19().coversWholeDays(berlin)).isTrue()
}
}
@@ -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.
@@ -65,4 +65,14 @@ class AgendaTimeLabelTest {
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
}
@Test
fun `a timed event from midnight to midnight is all-day`() {
val oneDay = event(at(2026, 7, 2), at(2026, 7, 3))
val twoDays = event(at(2026, 7, 2), at(2026, 7, 4))
assertThat(labelOn(2026, 7, 2, oneDay)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 2, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
assertThat(labelOn(2026, 7, 3, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
}
}
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.testDetail
import de.jeanlucmakiola.calendula.testEvent
@@ -27,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
/**
@@ -63,6 +66,7 @@ class RescheduleViewModelTest {
isAllDay: Boolean = false,
isException: Boolean = false,
anchorMillis: Long = beginMillis,
status: EventStatus = EventStatus.Confirmed,
): EventDetail = testDetail(
instance = testEvent(
id = 42L, title = "Standup",
@@ -72,6 +76,7 @@ class RescheduleViewModelTest {
),
rrule = rrule,
isException = isException,
status = status,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
@@ -671,4 +676,109 @@ class RescheduleViewModelTest {
assertThat(vm.scopePrompt.value)
.isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true))
}
@Test
fun `a cancelled series moves as a whole without asking`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY", status = EventStatus.Cancelled) }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).hasSize(1)
assertThat(fake.updatedOccurrences).isEmpty()
assertThat(fake.updatedFromOccurrences).isEmpty()
}
@Test
fun `a cancelled series whose rule cannot be realigned is not moved at all`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE", status = EventStatus.Cancelled) }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
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))
}
}
@@ -321,4 +321,18 @@ class MonthLayoutTest {
private fun declinedTimed(date: LocalDate) =
timed(date, 7, 8, id = 4L, title = "Declined early")
.copy(response = EventResponse.Declined)
@Test
fun `a timed event from midnight to midnight is a bar, not a timed chip`() {
val ev = timedEvent(
LocalDate(2026, 6, 10), 0, 0, zone = zone, id = 3L,
endDate = LocalDate(2026, 6, 11),
)
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
assertThat(week.spans.map { it.event }).containsExactly(ev)
assertThat(week.spans.single().startCol).isEqualTo(2)
assertThat(week.spans.single().endCol).isEqualTo(2)
assertThat(week.timedByDay.values.flatten()).isEmpty()
}
}
@@ -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<EventInstance>())
}
@Test
@@ -300,4 +302,30 @@ class WeekLayoutTest {
val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z"))
assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40)
}
@Test
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(listOf(ev).splitWholeDays(zone)).isEqualTo(listOf(ev) to emptyList<EventInstance>())
}
@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)
}
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.widget.countdown
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.days
@@ -22,9 +23,11 @@ class CountdownTest {
id: Long = 1,
allDay: Boolean = false,
response: EventResponse = EventResponse.Going,
status: EventStatus = EventStatus.Confirmed,
) = EventInstance(
instanceId = id, eventId = id, calendarId = 1, title = "e$id",
start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
status = status,
)
@Test
@@ -68,9 +71,16 @@ class CountdownTest {
val over = event(now - 2.hours, now - 1.hours, id = 3)
val later = event(now + 3.hours, id = 4)
val running = event(now - 1.hours, now + 2.hours, id = 5)
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now, zone)?.eventId).isEqualTo(4)
// A running event ending before the next one starts comes first.
assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
assertThat(pickNextEvent(listOf(later, running), now, zone)?.eventId).isEqualTo(5)
}
@Test
fun `next-event mode skips a timed event filling whole days`() {
val wholeDay = event(Instant.parse("2026-10-22T00:00:00Z"), Instant.parse("2026-10-23T00:00:00Z"), id = 1)
val later = event(now + 30.hours, id = 2)
assertThat(pickNextEvent(listOf(wholeDay, later), now, zone)?.eventId).isEqualTo(2)
}
@Test
@@ -81,6 +91,13 @@ class CountdownTest {
assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull()
}
@Test
fun `a pinned series skips cancelled occurrences`() {
val cancelled = event(now + 7.days, id = 1, status = EventStatus.Cancelled)
val next = event(now + 14.days, id = 2)
assertThat(pickPinnedOccurrence(listOf(cancelled, next), now, zone)).isEqualTo(next)
}
@Test
fun `an all-day event counts in local days west of UTC`() {
val newYork = TimeZone.of("America/New_York")
+1 -1
View File
@@ -295,7 +295,7 @@ Right before writing, the event is re-read and snapshots compared: a
mismatch parks the save in an overwrite/discard dialog; a vanished event
informs and closes. Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. Fields the form cannot
write (attendees, status, reminder methods) are excluded so sync noise
write (your own response, reminder methods) are excluded so sync noise
can't fake a conflict.
A **dropped** event gets no conflict dialog, deliberately. Its blast radius is