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.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
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.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -305,7 +310,7 @@ private fun CalendarsList(
|
|||||||
val disabled = !calendar.isVisibleInSystem
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
@@ -412,7 +417,11 @@ private fun CalendarsList(
|
|||||||
.forEach { (account, cals) ->
|
.forEach { (account, cals) ->
|
||||||
val expanded = account !in collapsedAccounts
|
val expanded = account !in collapsedAccounts
|
||||||
val accountType = cals.first().accountType
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = account,
|
title = account,
|
||||||
@@ -432,24 +441,36 @@ private fun CalendarsList(
|
|||||||
collapsedAccounts - account
|
collapsedAccounts - account
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showToggleAll = true,
|
showToggleAll = switchable.isNotEmpty(),
|
||||||
allEnabled = cals.all { it.isVisibleInSystem },
|
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||||
onToggleAll = { enabled -> onSetAccountVisible(cals.map { it.id }, enabled) },
|
onToggleAll = { enabled ->
|
||||||
|
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
cals.forEachIndexed { index, calendar ->
|
// Calendars you can act on first; the ones this device isn't
|
||||||
val disabled = !calendar.isVisibleInSystem
|
// syncing sit at the bottom, dimmed and switchless.
|
||||||
|
val ordered = cals.orderedForManager()
|
||||||
|
ordered.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
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,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
EnableSwitch(
|
{
|
||||||
calendarName = calendar.displayName,
|
EnableSwitch(
|
||||||
enabled = !disabled,
|
calendarName = calendar.displayName,
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
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 per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
|
||||||
* = the calendar is shown, unchecked = it drops out of every surface (events,
|
* = 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_a11y">Show \"%1$s\"</string>
|
||||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</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>
|
<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_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_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>
|
<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