Show event status in the calendar views and editor (#388)

This commit is contained in:
makiolaj committed 2026-11-02 21:10:58 +01:00
1 parent 8987573295
commit 459432d065
27 files changed
+193 -47

No files matched your search

@@ -588,13 +588,15 @@ class AndroidCalendarDataSource @Inject constructor(
): List<EventInstance> {
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),
@@ -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))
@@ -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),
)
}
@@ -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
}
/**
@@ -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),
)
}
@@ -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
@@ -42,6 +42,8 @@ data class EventForm(
val reminders: List<Int> = 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<EventFormField> = 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)
@@ -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
@@ -35,6 +35,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
description = description.orEmpty(),
reminders = semanticReminderMinutes(),
availability = availability,
status = status,
rrule = recurrenceRule.normalizedRrule(),
)
}
@@ -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,
@@ -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
@@ -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,
)
}
@@ -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 {
@@ -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.
@@ -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. */
@@ -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<EventInstance> { it.isDeclined }.thenBy { it.start })
.sortedWith(compareBy<EventInstance> { it.isCrossedOut }.thenBy { it.start })
},
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
)
@@ -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,
@@ -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))
@@ -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<String?> { null }
@@ -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(
@@ -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<EventInstance>, 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. */
@@ -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
}
+2
View File
@@ -154,6 +154,7 @@
<string name="reminder_unit_days">days</string>
<string name="reminder_unit_weeks">weeks</string>
<string name="event_edit_availability">Availability</string>
<string name="event_edit_status">Status</string>
<string name="event_edit_visibility">Visibility</string>
<!-- Event form — time zone -->
@@ -252,6 +253,7 @@
<!-- Event detail — full read (v0.6) -->
<string name="event_detail_reminders">Reminders</string>
<string name="event_detail_timezone">Time zone</string>
<string name="event_status_confirmed">Confirmed</string>
<string name="event_status_tentative">Tentative</string>
<string name="event_status_cancelled">Cancelled</string>
<string name="event_availability_free">Free</string>
@@ -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(
@@ -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()
}
}
@@ -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,
)
+1 -1
View File
@@ -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