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