Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd4bf965a | ||
|
|
90245b6893 |
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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
Submodule floret-kit updated: aef99a119f...1ff6702fe6.
Reference in new issue
Block a user