From 459432d065644ddfc6bc1e1d6871f962c2bdd5b5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 2 Nov 2026 21:10:52 +0100 Subject: [PATCH 1/2] Show event status in the calendar views and editor (#388) --- .../data/calendar/CalendarDataSource.kt | 14 ++++--- .../data/calendar/EventWriteMapper.kt | 5 +++ .../calendula/data/calendar/InstanceMapper.kt | 1 + .../calendula/data/calendar/Projections.kt | 4 ++ .../calendula/data/calendar/SearchMapper.kt | 1 + .../calendula/domain/EventDisplayOrder.kt | 2 +- .../calendula/domain/EventForm.kt | 7 +++- .../jeanlucmakiola/calendula/domain/Models.kt | 17 +++++++- .../calendula/domain/ics/ParsedIcsForm.kt | 1 + .../calendula/ui/agenda/AgendaRows.kt | 4 +- .../ui/common/EventFormFieldVisuals.kt | 3 ++ .../calendula/ui/common/EventPaint.kt | 20 ++++----- .../calendula/ui/common/PastEvents.kt | 10 ++--- .../calendula/ui/edit/EventEditScreen.kt | 37 +++++++++++++++++ .../calendula/ui/edit/EventEditViewModel.kt | 2 + .../calendula/ui/month/MonthViewModel.kt | 4 +- .../calendula/ui/search/SearchScreen.kt | 4 +- .../calendula/widget/WidgetEventRow.kt | 4 +- .../calendula/widget/WidgetTheme.kt | 4 +- .../calendula/widget/agenda/AgendaWidget.kt | 4 +- .../calendula/widget/countdown/Countdown.kt | 6 +-- .../calendula/widget/month/MonthGridViews.kt | 4 +- app/src/main/res/values/strings.xml | 2 + .../data/calendar/EventWriteMapperTest.kt | 35 ++++++++++++++++ .../data/calendar/InstanceMapperTest.kt | 41 +++++++++++++++++-- .../calendula/domain/EventFormTest.kt | 2 + docs/ARCHITECTURE.md | 2 +- 27 files changed, 193 insertions(+), 47 deletions(-) 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..8e3a193 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,15 @@ 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.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 1240c5f..0138511 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 isAllDay -> 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..06d96be 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( @@ -269,6 +273,7 @@ fun EventForm.populatedFields(): Set = 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 9ea1a91..ae091ca 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/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/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index d920b15..4c6ed9b 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 @@ -119,6 +119,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption @@ -892,6 +893,11 @@ 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, onSelect = viewModel::setStatus) + } + OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) { Spacer(Modifier.height(gap)) IconInfoCard( @@ -1343,6 +1349,37 @@ private fun AvailabilityCard(availability: Availability, onSelect: (Availability } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StatusCard(status: EventStatus, onSelect: (EventStatus) -> Unit) { + IconInfoCard( + icon = eventFormFieldIcon(EventFormField.Status), + iconContentDescription = null, + header = { + Text( + text = stringResource(R.string.event_edit_status), + style = MaterialTheme.typography.titleMedium, + ) + }, + ) { + Spacer(Modifier.height(8.dp)) + val options = listOf( + EventStatus.Confirmed to R.string.event_status_confirmed, + EventStatus.Tentative to R.string.event_status_tentative, + EventStatus.Cancelled to R.string.event_status_cancelled, + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, (value, label) -> + SegmentedButton( + selected = status == value, + onClick = { onSelect(value) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), + ) { Text(stringResource(label), maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } + } +} + /** * The swatch the event will paint with: its own [color], else the calendar's. * The Palette icon takes that colour as a preview. 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..8f7680a 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 @@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder 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 @@ -671,6 +672,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. */ 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 69b29c4..f588d8f 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 @@ -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 { 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 30ba653..e4ca6f2 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,7 +1,7 @@ package de.jeanlucmakiola.calendula.widget.countdown import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.domain.isCrossedOut import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn @@ -74,12 +74,12 @@ 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, 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. */ 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/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..01ff74a 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, ) 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 From 2d9b38abb6924ba7429a5616a074ed4c5ca8b7a4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 2 Nov 2026 21:26:30 +0100 Subject: [PATCH 2/2] Keep cancelled events from vanishing on edit (#388) --- .../data/calendar/CalendarDataSource.kt | 3 +- .../calendula/domain/EventForm.kt | 7 ++ .../calendula/ui/common/EventMoveHost.kt | 1 + .../ui/common/RescheduleViewModel.kt | 15 +++- .../calendula/ui/edit/EventEditScreen.kt | 83 +++++++------------ .../calendula/ui/edit/EventEditUiState.kt | 6 ++ .../calendula/ui/edit/EventEditViewModel.kt | 3 + .../calendula/widget/countdown/Countdown.kt | 5 +- .../calendula/widget/week/WeekStripWidget.kt | 7 +- .../calendula/domain/EventFormTest.kt | 7 ++ .../ui/common/RescheduleViewModelTest.kt | 40 +++++++++ .../widget/countdown/CountdownTest.kt | 10 +++ floret-kit | 2 +- 13 files changed, 128 insertions(+), 61 deletions(-) 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 8e3a193..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 @@ -596,7 +596,8 @@ class AndroidCalendarDataSource @Inject constructor( // keep the row's instances until a purge (#364). val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " + "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED} OR " + - "${CalendarContract.Instances.ORIGINAL_ID} IS NULL) AND " + + "(${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/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 06d96be..35e7d00 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -247,6 +247,13 @@ internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair 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, + ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun StatusCard(status: EventStatus, onSelect: (EventStatus) -> Unit) { - IconInfoCard( +private fun StatusCard(status: EventStatus, offerCancelled: Boolean, onSelect: (EventStatus) -> Unit) { + SegmentedChoiceCard( icon = eventFormFieldIcon(EventFormField.Status), - iconContentDescription = null, - header = { - Text( - text = stringResource(R.string.event_edit_status), - style = MaterialTheme.typography.titleMedium, - ) - }, - ) { - Spacer(Modifier.height(8.dp)) - val options = listOf( - EventStatus.Confirmed to R.string.event_status_confirmed, - EventStatus.Tentative to R.string.event_status_tentative, - EventStatus.Cancelled to R.string.event_status_cancelled, - ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - options.forEachIndexed { index, (value, label) -> - SegmentedButton( - selected = status == value, - onClick = { onSelect(value) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), - ) { Text(stringResource(label), maxLines = 1, overflow = TextOverflow.Ellipsis) } - } - } - } + 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 8f7680a..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,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder +import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus @@ -336,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, ) @@ -779,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/widget/countdown/Countdown.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt index e4ca6f2..e2065e5 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,6 +1,7 @@ package de.jeanlucmakiola.calendula.widget.countdown import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.isCrossedOut import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone @@ -82,9 +83,9 @@ fun pickNextEvent(instances: List, now: Instant): EventInstance? .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, 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/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/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index 01ff74a..bcc1d29 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -572,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 6cd4ed4..98542dc 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 @@ -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") 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