Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9b38abb6 | ||
|
|
459432d065 |
No files matched your search
@@ -588,13 +588,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
): 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).
|
||||
// cancelled exception for the one instance (#47). A cancelled event or
|
||||
// series of its own stays, crossed out (#388), so only exception rows
|
||||
// are filtered. A NULL status is a normal, un-cancelled event, so it
|
||||
// must survive the filter — a bare `!= CANCELED` would drop it (NULL !=
|
||||
// 2 is NULL, not true). DELETED = 0: a provider that soft-deletes may
|
||||
// keep the row's instances until a purge (#364).
|
||||
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),
|
||||
|
||||
@@ -132,6 +132,7 @@ internal fun buildEventInsertValues(
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
if (form.status != EventStatus.Confirmed) put(CalendarContract.Events.STATUS, form.status.toProviderStatus())
|
||||
form.location.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
form.description.trim().takeIf { it.isNotEmpty() }
|
||||
@@ -179,6 +180,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 +283,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, form.status.toProviderStatus())
|
||||
put(CalendarContract.Events.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))
|
||||
|
||||
@@ -38,6 +38,7 @@ 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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 @@ 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
|
||||
}
|
||||
|
||||
internal object EventDetailProjection {
|
||||
@@ -198,6 +200,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 +217,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),
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import kotlinx.datetime.TimeZone
|
||||
|
||||
/** 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
|
||||
else -> 2
|
||||
|
||||
@@ -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,13 @@ 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 as a single modified occurrence. A cancelled
|
||||
* exception row is how Android records a deleted occurrence, so a cancelled one
|
||||
* would vanish rather than show struck through (#388).
|
||||
*/
|
||||
fun EventForm.canSaveAsOccurrence(): Boolean = status != EventStatus.Cancelled
|
||||
|
||||
/**
|
||||
* 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 +280,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +35,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
description = description.orEmpty(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
availability = availability,
|
||||
status = status,
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,6 +68,7 @@ fun EventMoveHost(
|
||||
),
|
||||
onSelect = viewModel::moveWithScope,
|
||||
onDismiss = viewModel::cancelScope,
|
||||
allowOccurrence = !pending.seriesOnly,
|
||||
allowSeries = !pending.occurrenceOnly,
|
||||
reason = stringResource(R.string.event_move_occurrence_only)
|
||||
.takeIf { pending.occurrenceOnly },
|
||||
|
||||
@@ -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.canSaveAsOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
@@ -79,7 +80,12 @@ data class MoveRequest(
|
||||
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||
* across the same midnights the occurrence crossed.
|
||||
*/
|
||||
data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
|
||||
data class MoveScopePrompt(
|
||||
val occurrenceOnly: Boolean,
|
||||
val resize: Boolean = false,
|
||||
/** A cancelled series, whose occurrences can't move on their own (#388). */
|
||||
val seriesOnly: Boolean = false,
|
||||
)
|
||||
|
||||
/** The inverse of a completed move, for the undo action. */
|
||||
data class MoveUndo(
|
||||
@@ -227,15 +233,21 @@ class RescheduleViewModel @Inject constructor(
|
||||
busy = false
|
||||
return@launch
|
||||
}
|
||||
val seriesOnly = !prepared.original.canSaveAsOccurrence()
|
||||
if (!prepared.isRecurring) {
|
||||
write(prepared, RecurringWriteScope.AllEvents)
|
||||
busy = false
|
||||
} else if (seriesOnly && !prepared.canRealign) {
|
||||
// Neither the occurrence nor the series can take this move.
|
||||
_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,
|
||||
seriesOnly = seriesOnly,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -247,6 +259,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
val prepared = pending ?: return
|
||||
// Guard against the dialog ever offering a scope the rule can't carry.
|
||||
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
||||
if (scope == RecurringWriteScope.ThisEvent && !prepared.original.canSaveAsOccurrence()) return
|
||||
pending = null
|
||||
_scopePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -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
|
||||
@@ -117,8 +118,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
@@ -449,12 +452,12 @@ fun EventEditScreen(
|
||||
|
||||
if (state?.saveState == SaveUiState.AwaitingScope) {
|
||||
// A changed rule rules out the single occurrence — an exception row
|
||||
// can't carry its own rule.
|
||||
// can't carry its own rule — and so does a cancelled one (#388).
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_edit_recurring_title),
|
||||
onSelect = viewModel::saveWithScope,
|
||||
onDismiss = viewModel::consumeSaveResult,
|
||||
allowOccurrence = state?.recurrenceChanged != true,
|
||||
allowOccurrence = state?.recurrenceChanged != true && state?.form?.canSaveAsOccurrence() != false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -892,6 +895,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 +1326,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.canSaveAsOccurrence
|
||||
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. */
|
||||
@@ -777,6 +781,7 @@ class EventEditViewModel @Inject constructor(
|
||||
fun saveWithScope(scope: RecurringWriteScope) {
|
||||
val current = state.value ?: return
|
||||
if (current.saveState != SaveUiState.AwaitingScope) return
|
||||
if (scope == RecurringWriteScope.ThisEvent && !current.form.canSaveAsOccurrence()) return
|
||||
performSave(current.form, scope)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ 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.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
@@ -510,7 +510,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) } },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -499,7 +499,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,
|
||||
|
||||
@@ -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,8 @@
|
||||
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.isCrossedOut
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
@@ -74,17 +75,17 @@ fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant
|
||||
}
|
||||
|
||||
/**
|
||||
* The next-event mode's pick: timed, not declined, not over; of those, the one
|
||||
* The next-event mode's pick: timed, 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? =
|
||||
instances
|
||||
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
|
||||
.filter { !it.isAllDay && !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
|
||||
|
||||
@@ -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.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
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 cancelled event is no plan for the day, so it adds no dot and no count (#388).
|
||||
val events = data.eventsOn(date).filter { it.status != EventStatus.Cancelled }
|
||||
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,
|
||||
|
||||
@@ -154,6 +154,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 +253,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,40 @@ 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)
|
||||
}
|
||||
|
||||
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
|
||||
|
||||
private fun masterSnapshot(
|
||||
|
||||
@@ -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,33 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -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,11 @@ 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 cannot be saved as a single occurrence`() {
|
||||
assertThat(form().canSaveAsOccurrence()).isTrue()
|
||||
assertThat(form().copy(status = EventStatus.Tentative).canSaveAsOccurrence()).isTrue()
|
||||
assertThat(form().copy(status = EventStatus.Cancelled).canSaveAsOccurrence()).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -63,6 +64,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 +74,7 @@ class RescheduleViewModelTest {
|
||||
),
|
||||
rrule = rrule,
|
||||
isException = isException,
|
||||
status = status,
|
||||
)
|
||||
|
||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
|
||||
@@ -671,4 +674,41 @@ class RescheduleViewModelTest {
|
||||
assertThat(vm.scopePrompt.value)
|
||||
.isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled series offers no single-occurrence move`(
|
||||
@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).isEqualTo(MoveScopePrompt(occurrenceOnly = false, seriesOnly = true))
|
||||
|
||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
||||
advanceUntilIdle()
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
assertThat(vm.scopePrompt.value).isNotNull()
|
||||
}
|
||||
|
||||
@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.abandoned.value).isEqualTo(1)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -81,6 +84,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...ac73a0c85a.
Reference in new issue
Block a user