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() + } +}