diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index de1254b..36ce793 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -588,13 +588,16 @@ class AndroidCalendarDataSource @Inject constructor( ): List { 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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 98dbe3c..3fe03f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -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)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index e8b49c6..8d6cb7a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -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), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index e9dae51..69dc43c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -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 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index 1b7dcf2..81d4f86 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -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), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt index d71a544..483464f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt @@ -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 coversWholeDays(zone) -> 1 else -> 2 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index bbd9646..35e7d00 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -42,6 +42,8 @@ data class EventForm( val reminders: List = 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 = 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 3ec5643..49cc5a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt index 1467e04..b2db413 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -35,6 +35,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { description = description.orEmpty(), reminders = semanticReminderMinutes(), availability = availability, + status = status, rrule = recurrenceRule.normalizedRrule(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index 0859beb..3c91a94 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt index 91d84a5..33f3b7d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index abf3978..27d3ee7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -68,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 }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt index f29321e..99db3d0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt @@ -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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt index 14d8de8..d0fea92 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index ba1f245..66864c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -6,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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index d920b15..47e7825 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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, + ) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 1be5d3b..f96ee37 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 8bfddab..426186b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -14,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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index a0f1783..39e2233 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -20,7 +20,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.sortedForDisplay import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay @@ -511,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 { it.isDeclined }.thenBy { it.start }) + .sortedWith(compareBy { it.isCrossedOut }.thenBy { it.start }) }, countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 58f39a5..df35de1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt index 90629b9..311715f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt @@ -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)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt index 271fa34..6dd319e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -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 { null } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 14ad34b..03dde16 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt index 2c76617..b35eecb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt @@ -1,8 +1,9 @@ package de.jeanlucmakiola.calendula.widget.countdown import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.coversWholeDays -import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.domain.isCrossedOut import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn @@ -76,17 +77,17 @@ fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant /** * The next-event mode's pick: timed and not filling whole days (#296), not - * declined, not over; of those, the one whose countdown changes soonest — a - * running event's end, else a start. + * 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, now: Instant, zone: TimeZone): EventInstance? = instances - .filter { !it.coversWholeDays(zone) && !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, 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index 80b3ae0..f5854d2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt index 81cb2c2..a6720c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt @@ -1,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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 371fcbc..b9baff1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -154,6 +154,7 @@ days weeks Availability + Status Visibility @@ -252,6 +253,7 @@ Reminders Time zone + Confirmed Tentative Cancelled Free diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 802d89d..358ba57 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -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( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt index 2c58954..c4bc0d9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt @@ -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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index cd9f7d5..bcc1d29 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index 93ef853..a70c742 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt index 0ccab50..b88784c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt @@ -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 @@ -88,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") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5d58523..771d9bd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/floret-kit b/floret-kit index aef99a1..ac73a0c 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit aef99a119f0e50fed3aae9777d808c484d4c1d04 +Subproject commit ac73a0c85acdf3f72ca18a17baec84b6407d8486