Keep cancelled events from vanishing on edit (#388)

This commit is contained in:
makiolaj committed 2026-11-02 21:26:30 +01:00
1 parent 459432d065
commit 2d9b38abb6
13 files changed
+128 -61

No files matched your search

@@ -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),
@@ -247,6 +247,13 @@ internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<F
/** The bare RRULE value the form edits; blank means none. */
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
/**
* Whether this form may be saved as a single modified occurrence. A cancelled
* exception row is how Android records a deleted occurrence, so a cancelled one
* would vanish rather than show struck through (#388).
*/
fun EventForm.canSaveAsOccurrence(): Boolean = status != EventStatus.Cancelled
/**
* Whether this form's times mean something else in [target] — a timed event
* pinned to another zone. False when there's nothing to disambiguate: an
@@ -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 },
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem
@@ -79,7 +80,12 @@ data class MoveRequest(
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
* across the same midnights the occurrence crossed.
*/
data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
data class MoveScopePrompt(
val occurrenceOnly: Boolean,
val resize: Boolean = false,
/** A cancelled series, whose occurrences can't move on their own (#388). */
val seriesOnly: Boolean = false,
)
/** The inverse of a completed move, for the undo action. */
data class MoveUndo(
@@ -227,15 +233,21 @@ class RescheduleViewModel @Inject constructor(
busy = false
return@launch
}
val seriesOnly = !prepared.original.canSaveAsOccurrence()
if (!prepared.isRecurring) {
write(prepared, RecurringWriteScope.AllEvents)
busy = false
} else if (seriesOnly && !prepared.canRealign) {
// Neither the occurrence nor the series can take this move.
_abandoned.value += 1
busy = false
} else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
resize = request.target is MoveTarget.Span,
seriesOnly = seriesOnly,
)
}
}
@@ -247,6 +259,7 @@ class RescheduleViewModel @Inject constructor(
val prepared = pending ?: return
// Guard against the dialog ever offering a scope the rule can't carry.
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
if (scope == RecurringWriteScope.ThisEvent && !prepared.original.canSaveAsOccurrence()) return
pending = null
_scopePrompt.value = null
viewModelScope.launch {
@@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.components.SegmentedChoiceCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
@@ -117,6 +118,7 @@ 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
@@ -450,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,
)
}
@@ -895,7 +897,11 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Status in state.visibleFields) {
Spacer(Modifier.height(gap))
StatusCard(status = form.status, onSelect = viewModel::setStatus)
StatusCard(
status = form.status,
offerCancelled = state.offerCancelled,
onSelect = viewModel::setStatus,
)
}
OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) {
@@ -1320,64 +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,
)
}
@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,
)
}
/**
@@ -48,6 +48,12 @@ data class EventEditUiState(
* then drops "only this event" (an exception row can't carry a rule).
*/
val recurrenceChanged: Boolean = false,
/**
* Whether the Status field offers Cancelled: only for an event that already
* is. Google reads a cancelled event as deleted, so picking it is no safe
* way to strike an event through (#388).
*/
val offerCancelled: Boolean = false,
/**
* The event-colour palette the resolved target calendar publishes; empty
* when it exposes none. Non-empty → the colour picker offers these swatches
@@ -14,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)
}
@@ -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<EventInstance>, 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<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
occurrences.filter { it.status != EventStatus.Cancelled && it.counted(zone).end > now }.minByOrNull { it.start }
/**
* The span a countdown counts over. An all-day event is stored on UTC
@@ -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,
@@ -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()
}
}
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.testDetail
import de.jeanlucmakiola.calendula.testEvent
@@ -63,6 +64,7 @@ class RescheduleViewModelTest {
isAllDay: Boolean = false,
isException: Boolean = false,
anchorMillis: Long = beginMillis,
status: EventStatus = EventStatus.Confirmed,
): EventDetail = testDetail(
instance = testEvent(
id = 42L, title = "Standup",
@@ -72,6 +74,7 @@ class RescheduleViewModelTest {
),
rrule = rrule,
isException = isException,
status = status,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
@@ -671,4 +674,41 @@ class RescheduleViewModelTest {
assertThat(vm.scopePrompt.value)
.isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true))
}
@Test
fun `a cancelled series offers no single-occurrence move`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY", status = EventStatus.Cancelled) }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false, seriesOnly = true))
vm.moveWithScope(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
assertThat(fake.updatedOccurrences).isEmpty()
assertThat(vm.scopePrompt.value).isNotNull()
}
@Test
fun `a cancelled series whose rule cannot be realigned is not moved at all`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE", status = EventStatus.Cancelled) }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(vm.abandoned.value).isEqualTo(1)
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.widget.countdown
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.days
@@ -22,9 +23,11 @@ class CountdownTest {
id: Long = 1,
allDay: Boolean = false,
response: EventResponse = EventResponse.Going,
status: EventStatus = EventStatus.Confirmed,
) = EventInstance(
instanceId = id, eventId = id, calendarId = 1, title = "e$id",
start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
status = status,
)
@Test
@@ -81,6 +84,13 @@ class CountdownTest {
assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull()
}
@Test
fun `a pinned series skips cancelled occurrences`() {
val cancelled = event(now + 7.days, id = 1, status = EventStatus.Cancelled)
val next = event(now + 14.days, id = 2)
assertThat(pickPinnedOccurrence(listOf(cancelled, next), now, zone)).isEqualTo(next)
}
@Test
fun `an all-day event counts in local days west of UTC`() {
val newYork = TimeZone.of("America/New_York")