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