From d16c21f15aadae8bbf3de462087bfc7b16130015 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 25 Jul 2026 22:24:50 +0200 Subject: [PATCH] feat(calendars): say what is different about a calendar (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar manager knew four things about a calendar and told the user one of them (off, via the switch). Read-only calendars — a subscription, a share you can only view — looked identical to writable ones while being silently absent from every picker, and a calendar whose account isn't syncing events to this device looked entirely normal while being empty by construction. Both now carry a supporting line on their row, not a badge: a row can hold several of these at once next to a live switch, which is what M3 supporting text composes and a row of static chips doesn't. The non-syncing case also loses its switch, sorts to the bottom of its account and dims. Visibility can't reveal what isn't on the device, so the control would do nothing — and it no longer counts towards "the whole account is off" or moves with toggle-all. sync_events is read as "not synced" for account-backed calendars only. Nothing syncs a device-local calendar by definition, so the flag says nothing there, and another app's local calendar can hold real events at sync_events=0 — the same unsoundness that made the first #75 migration guard wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../calendula/domain/CalendarRowState.kt | 49 ++++++++++++ .../calendula/ui/calendars/CalendarsScreen.kt | 69 +++++++++++++---- app/src/main/res/values/strings.xml | 5 ++ .../calendula/domain/CalendarRowStateTest.kt | 75 +++++++++++++++++++ 4 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt new file mode 100644 index 0000000..ed41e7e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt @@ -0,0 +1,49 @@ +package de.jeanlucmakiola.calendula.domain + +/** + * The ways a calendar can behave unlike a plain, writable one — each of them a + * reason it is missing from the event and import pickers, and each of them + * something the app knows and used to keep to itself (#76). + */ +enum class CalendarStateLabel { + /** Contents can't be modified: a WebCal subscription, a read-only share. */ + READ_ONLY, + + /** The account holds the events, but this device isn't syncing them down. */ + NOT_SYNCED, +} + +/** + * Whether the account this calendar belongs to keeps its events off the device + * (`Calendars.SYNC_EVENTS = 0`) — an "empty by construction" calendar: the rows + * simply aren't here, so nothing can display them and no reminder can fire. + * + * Device-local calendars are excluded deliberately. Nothing syncs them by + * definition, so the flag says nothing about them, and a local calendar from + * another app can hold real events at `sync_events = 0` — the same unsoundness + * that made the #75 migration guard wrong. + */ +val CalendarSource.isNotSynced: Boolean + get() = !syncsEvents && !isLocal + +/** + * Whether a visibility switch on this calendar can change anything the user + * would see. It can't for a non-syncing one: there are no events on the device + * to reveal, so the switch would be a control that does nothing. + */ +val CalendarSource.hasVisibilitySwitch: Boolean + get() = !isNotSynced + +/** Every state worth naming on this calendar's row, in reading order. */ +fun CalendarSource.stateLabels(): List = buildList { + if (!canModifyContents) add(CalendarStateLabel.READ_ONLY) + if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED) +} + +/** + * Calendar-manager order within one group: the ones you can actually act on + * first, the non-syncing ones after them. Stable otherwise, so the provider's + * display-name ordering survives. + */ +fun List.orderedForManager(): List = + sortedBy { it.isNotSynced } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 9ed5226..b213fbc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -91,6 +91,11 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.CalendarStateLabel +import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch +import de.jeanlucmakiola.calendula.domain.isNotSynced +import de.jeanlucmakiola.calendula.domain.orderedForManager +import de.jeanlucmakiola.calendula.domain.stateLabels import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -305,7 +310,7 @@ private fun CalendarsList( val disabled = !calendar.isVisibleInSystem GroupedRow( title = calendar.displayName, - summary = calendar.description, + summary = calendarRowSummary(calendar), position = if (index == local.lastIndex) Position.Bottom else Position.Middle, container = MaterialTheme.colorScheme.surfaceContainerHighest, dimmed = disabled, @@ -412,7 +417,11 @@ private fun CalendarsList( .forEach { (account, cals) -> val expanded = account !in collapsedAccounts val accountType = cals.first().accountType - val accountDisabled = cals.none { it.isVisibleInSystem } + // A non-syncing calendar has no switch, so it neither counts + // towards "the whole account is off" nor moves with toggle-all. + val switchable = cals.filter { it.hasVisibilitySwitch } + val accountDisabled = switchable.isNotEmpty() && + switchable.none { it.isVisibleInSystem } Spacer(Modifier.height(16.dp)) CalendarGroup( title = account, @@ -432,24 +441,36 @@ private fun CalendarsList( collapsedAccounts - account } }, - showToggleAll = true, - allEnabled = cals.all { it.isVisibleInSystem }, - onToggleAll = { enabled -> onSetAccountVisible(cals.map { it.id }, enabled) }, + showToggleAll = switchable.isNotEmpty(), + allEnabled = switchable.all { it.isVisibleInSystem }, + onToggleAll = { enabled -> + onSetAccountVisible(switchable.map { it.id }, enabled) + }, ) { - cals.forEachIndexed { index, calendar -> - val disabled = !calendar.isVisibleInSystem + // Calendars you can act on first; the ones this device isn't + // syncing sit at the bottom, dimmed and switchless. + val ordered = cals.orderedForManager() + ordered.forEachIndexed { index, calendar -> + val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced GroupedRow( title = calendar.displayName, - position = if (index == cals.lastIndex) Position.Bottom else Position.Middle, + summary = calendarRowSummary(calendar), + position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle, container = MaterialTheme.colorScheme.surfaceContainerHighest, dimmed = disabled, leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, - trailing = { - EnableSwitch( - calendarName = calendar.displayName, - enabled = !disabled, - onToggle = { enabled -> onSetVisible(calendar.id, enabled) }, - ) + trailing = if (calendar.hasVisibilitySwitch) { + { + EnableSwitch( + calendarName = calendar.displayName, + enabled = calendar.isVisibleInSystem, + onToggle = { enabled -> + onSetVisible(calendar.id, enabled) + }, + ) + } + } else { + null }, ) } @@ -694,6 +715,26 @@ private fun CalendarEditor( } } +/** + * The row's supporting line: the states that make this calendar behave unlike a + * plain writable one (#76), then its own description. Text rather than badges — + * a row can carry several of these at once next to a switch, which is exactly + * what M3 supporting text composes and a row of static chips doesn't. + */ +@Composable +private fun calendarRowSummary(calendar: CalendarSource): String? { + val states = calendar.stateLabels().map { label -> + stringResource( + when (label) { + CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only + CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced + }, + ) + } + val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() }) + return parts.joinToString(" · ").ifEmpty { null } +} + /** * The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked * = the calendar is shown, unchecked = it drops out of every surface (events, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a382653..1a644c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -477,6 +477,11 @@ Show \"%1$s\" Some calendars are switched off Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars. + + Missing a calendar? + It may be switched off on this device, or read-only — manage your calendars here. + Read-only + Not synced to this device Synced calendars These come from accounts on your device. Create and edit them in their own app. Manage in app diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt new file mode 100644 index 0000000..e7c9a97 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt @@ -0,0 +1,75 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class CalendarRowStateTest { + + private fun cal( + id: Long = 1L, + name: String = "Cal $id", + writable: Boolean = true, + syncsEvents: Boolean = true, + local: Boolean = false, + ) = CalendarSource( + id = id, + displayName = name, + accountName = "account", + accountType = if (local) "LOCAL" else "com.google", + color = 0, + isVisibleInSystem = true, + canModifyContents = writable, + isLocal = local, + syncsEvents = syncsEvents, + ) + + @Test + fun `a plain writable calendar carries no state labels`() { + assertThat(cal().stateLabels()).isEmpty() + assertThat(cal().hasVisibilitySwitch).isTrue() + } + + @Test + fun `a read-only calendar is labelled`() { + assertThat(cal(writable = false).stateLabels()) + .containsExactly(CalendarStateLabel.READ_ONLY) + } + + @Test + fun `a non-syncing account calendar is labelled and loses its switch`() { + val calendar = cal(syncsEvents = false) + assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.NOT_SYNCED) + assertThat(calendar.hasVisibilitySwitch).isFalse() + } + + @Test + fun `both states can hold at once, read-only first`() { + assertThat(cal(writable = false, syncsEvents = false).stateLabels()) + .containsExactly(CalendarStateLabel.READ_ONLY, CalendarStateLabel.NOT_SYNCED) + .inOrder() + } + + @Test + fun `a local calendar is never called not-synced`() { + // Nothing syncs a device-local calendar, so sync_events says nothing + // about it — and another app's local calendar can hold real events at 0. + val calendar = cal(syncsEvents = false, local = true) + assertThat(calendar.isNotSynced).isFalse() + assertThat(calendar.stateLabels()).isEmpty() + assertThat(calendar.hasVisibilitySwitch).isTrue() + } + + @Test + fun `manager order puts non-syncing calendars last and is otherwise stable`() { + val ordered = listOf( + cal(id = 1L, name = "Anna", syncsEvents = false), + cal(id = 2L, name = "Bert"), + cal(id = 3L, name = "Cleo", syncsEvents = false), + cal(id = 4L, name = "Dana"), + ).orderedForManager() + + assertThat(ordered.map { it.displayName }) + .containsExactly("Bert", "Dana", "Anna", "Cleo") + .inOrder() + } +}