feat(calendars): say what is different about a calendar (#76)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<CalendarStateLabel> = 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<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
||||
sortedBy { it.isNotSynced }
|
||||
@@ -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,
|
||||
|
||||
@@ -477,6 +477,11 @@
|
||||
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
||||
<string name="calendars_visibility_notice_message">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.</string>
|
||||
<!-- Footer row under the event-form and .ics import calendar pickers. -->
|
||||
<string name="calendar_picker_missing_title">Missing a calendar?</string>
|
||||
<string name="calendar_picker_missing_summary">It may be switched off on this device, or read-only — manage your calendars here.</string>
|
||||
<string name="calendars_state_read_only">Read-only</string>
|
||||
<string name="calendars_state_not_synced">Not synced to this device</string>
|
||||
<string name="calendars_synced_header">Synced calendars</string>
|
||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||
<string name="calendars_manage_in_app">Manage in app</string>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user