Keep cancelled events from vanishing on edit (#388)
This commit is contained in:
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")
|
||||
|
||||
+1
-1
Submodule floret-kit updated: aef99a119f...ac73a0c85a.
Reference in new issue
Block a user