From bc805a9a3aa5da7ff1b85d5bac8e25110f79dd74 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 17 Aug 2026 17:33:59 +0200 Subject: [PATCH] Mark declined invitations and stop planning their reminders (#180) --- .../calendula/data/calendar/InstanceMapper.kt | 3 ++ .../calendula/data/calendar/Projections.kt | 4 +++ .../calendula/data/calendar/SearchMapper.kt | 3 ++ .../data/reminders/ReminderInstanceSource.kt | 9 +++++- .../jeanlucmakiola/calendula/domain/Models.kt | 7 +++++ .../calendula/ui/agenda/AgendaRows.kt | 6 ++-- .../calendula/ui/common/PastEvents.kt | 31 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 3 ++ .../calendula/ui/month/MonthScreen.kt | 2 ++ .../calendula/ui/search/SearchScreen.kt | 3 +- .../calendula/ui/week/WeekScreen.kt | 3 ++ .../calendula/widget/WidgetTheme.kt | 8 +++++ .../calendula/widget/agenda/AgendaWidget.kt | 7 ++++- .../calendula/widget/month/MonthWidget.kt | 7 ++++- .../data/calendar/InstanceMapperTest.kt | 19 ++++++++++++ 15 files changed, 109 insertions(+), 6 deletions(-) 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 6c72def..3a3eb92 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 @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.data.calendar import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis +import android.provider.CalendarContract import android.util.Log import de.jeanlucmakiola.calendula.domain.EventInstance @@ -38,5 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? { isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0, color = color, location = getString(InstanceProjection.IDX_LOCATION), + isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) == + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED, ) } 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 ef58672..264c5ac 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 @@ -52,6 +52,7 @@ internal object InstanceProjection { CalendarContract.Instances.EVENT_COLOR, CalendarContract.Instances.CALENDAR_COLOR, CalendarContract.Instances.EVENT_LOCATION, + CalendarContract.Instances.SELF_ATTENDEE_STATUS, ) const val IDX_INSTANCE_ID = 0 @@ -64,6 +65,7 @@ internal object InstanceProjection { const val IDX_EVENT_COLOR = 7 const val IDX_CALENDAR_COLOR = 8 const val IDX_LOCATION = 9 + const val IDX_SELF_ATTENDEE_STATUS = 10 } internal object EventDetailProjection { @@ -182,6 +184,7 @@ internal object SearchProjection { CalendarContract.Events.RDATE, // Excerpted, not just filtered on: a hit has to show what it matched. CalendarContract.Events.DESCRIPTION, + CalendarContract.Events.SELF_ATTENDEE_STATUS, ) const val IDX_ID = 0 @@ -197,6 +200,7 @@ internal object SearchProjection { const val IDX_RRULE = 10 const val IDX_RDATE = 11 const val IDX_DESCRIPTION = 12 + const val IDX_SELF_ATTENDEE_STATUS = 13 } /** 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 d3baf96..a0e23b9 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.provider.CalendarContract import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis @@ -42,5 +43,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? { location = getString(SearchProjection.IDX_LOCATION), isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || !getString(SearchProjection.IDX_RDATE).isNullOrEmpty(), + isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) == + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt index 3b1c2e9..6b13564 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt @@ -44,9 +44,16 @@ class ProviderReminderInstanceSource @Inject constructor( // `visible` is the flag the app's one visibility model writes (#75). // The status clause mirrors CalendarDataSource.instances: NULL means // "normal", so a bare `!= CANCELED` would drop every ordinary event. + // An invitation the user declined is answered — it stays on the calendar + // struck through, but it plans nothing (#180). NULL again means "no + // answer recorded", which is not a "no". val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " + "(${CalendarContract.Instances.STATUS} IS NULL OR " + - "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})" + "${CalendarContract.Instances.STATUS} != " + + "${CalendarContract.Events.STATUS_CANCELED}) AND " + + "(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " + + "${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " + + "${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})" return context.contentResolver.query( uri, OCCURRENCE_PROJECTION, selection, null, null, )?.use { c -> 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 4021109..e6328e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -67,6 +67,13 @@ data class EventInstance( * Instances query already yields one row per occurrence. */ val isRecurring: Boolean = false, + /** + * This device user answered "no" to the invitation + * (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is + * still an appointment someone expects an answer about — but every surface + * strikes it through, and it plans no reminders (#180). + */ + val isDeclined: Boolean = false, ) /** 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 f2fdd95..4b72c36 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 @@ -24,11 +24,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString 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.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.eventAccent @@ -129,8 +131,8 @@ internal fun AgendaEventRow( val title = event.title.ifBlank { stringResource(R.string.event_untitled) } GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, - title = title, - summary = agendaTimeSummary(event, day, zone), + title = declinedTitle(title, event.isDeclined), + summary = AnnotatedString(agendaTimeSummary(event, day, zone)), position = position, minHeight = 64.dp, leading = { 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 d2b9056..0e73450 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 @@ -1,6 +1,10 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration import kotlin.time.Instant /** @@ -14,3 +18,30 @@ val LocalDimCutoff = compositionLocalOf { null } /** Opacity applied to a completed/past event chip when it is dimmed. */ const val EventDimAlpha = 0.4f + +/** + * How a declined invitation's title is struck through (#180) — the mark every + * surface uses to say "you answered no", chosen over hiding the event because it + * 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 + +/** [declinedDecoration] for the rows that take styled text rather than a `String`. */ +fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) { + 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) { + title + } else { + buildAnnotatedString { + append(title) + addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), 0, title.length) + } + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 6b6d4fe..49b5f38 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -106,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -525,6 +526,7 @@ private fun AllDayBar( maxLines = 1, overflow = TextOverflow.Ellipsis, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } @@ -772,6 +774,7 @@ private fun EventBlock( maxLines = if (showTime) 1 else 2, overflow = TextOverflow.Ellipsis, color = eventInk(fill, alpha = 0.85f), + textDecoration = declinedDecoration(block.event.isDeclined), ) } if (showTime) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index ffcccf6..9770cb6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -138,6 +138,7 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventAccent @@ -2358,6 +2359,7 @@ private fun MonthBar( maxLines = 1, overflow = TextOverflow.Ellipsis, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } 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 508dcbe..68dfcda 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 @@ -85,6 +85,7 @@ import de.jeanlucmakiola.calendula.domain.SearchMonth import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBack +import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position @@ -519,7 +520,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 = marked(event.title, hit.titleSpans, highlight), + title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined), summary = searchSummary(hit, highlight), position = position, minHeight = 64.dp, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 869c3a2..a88f1c9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha @@ -662,6 +663,7 @@ private fun AllDayBar( maxLines = 1, overflow = TextOverflow.Ellipsis, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } @@ -940,6 +942,7 @@ private fun EventBlock( maxLines = titleMaxLines, overflow = TextOverflow.Ellipsis, color = eventInk(fill, alpha = 0.85f), + textDecoration = declinedDecoration(block.event.isDeclined), ) } if (showTime) { 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 a529f0f..d679fe5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -4,6 +4,7 @@ import android.os.Build import androidx.compose.runtime.Composable import androidx.glance.GlanceTheme import androidx.glance.material3.ColorProviders +import androidx.glance.text.TextDecoration import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback @@ -34,3 +35,10 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) { } GlanceTheme(colors = colors, content = content) } + +/** + * 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 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 c40288a..074a2ad 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.agenda +import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable @@ -416,7 +417,11 @@ private fun EventRow( Text( text = title, maxLines = 1, - style = TextStyle(color = titleColor, fontSize = metrics.eventTitle), + style = TextStyle( + color = titleColor, + fontSize = metrics.eventTitle, + textDecoration = glanceDeclinedDecoration(event.isDeclined), + ), ) Text( text = eventTimeSummary(context, event, day, is24Hour), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 24f19a3..b40cb03 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.month +import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable @@ -414,7 +415,11 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Text( text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, - style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp), + style = TextStyle( + color = ColorProvider(eventInk(fill)), + fontSize = 9.sp, + textDecoration = glanceDeclinedDecoration(event.isDeclined), + ), modifier = GlanceModifier.padding(horizontal = 3.dp), ) } 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 a546014..1c7dd1b 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat import kotlin.time.Instant import org.junit.jupiter.api.Test @@ -17,6 +18,7 @@ class InstanceMapperTest { eventColor: Any? = null, calendarColor: Int = 0xFFAABBCC.toInt(), location: String? = null, + selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE, ): MapColumnReader = MapColumnReader( InstanceProjection.IDX_INSTANCE_ID to instanceId, InstanceProjection.IDX_EVENT_ID to eventId, @@ -28,6 +30,7 @@ class InstanceMapperTest { InstanceProjection.IDX_EVENT_COLOR to eventColor, InstanceProjection.IDX_CALENDAR_COLOR to calendarColor, InstanceProjection.IDX_LOCATION to location, + InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus, ) @Test @@ -90,4 +93,20 @@ class InstanceMapperTest { val inst = reader(location = "Berlin").toEventInstance() assertThat(inst!!.location).isEqualTo("Berlin") } + + @Test + fun `a declined invitation is marked, any other answer is not`() { + assertThat(reader().toEventInstance()!!.isDeclined).isFalse() + assertThat( + reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED) + .toEventInstance()!!.isDeclined, + ).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() + } + } }