Compare commits

...

2 Commits

Author SHA1 Message Date
5b64110be6 feat(pickers): point at the calendar manager when one is missing (#76)
The event form's and the .ics import screen's calendar pickers list event
targets only, so a calendar that is switched off, read-only or managed is
simply absent — which reads as a missing calendar rather than an excluded
one. Both pickers now end in a "Missing a calendar?" row that names the
possible reasons and opens Settings → Calendars, where the row labels added
in the previous commit say which one applies.

Keeping the excluded calendars out of the picker stays deliberate: with a
handful of read-only subscriptions they would crowd out the calendars you
can actually pick, every single time you choose one.

The manager overlay moves to the end of the host's overlay stack so it
covers every surface that can now open it — Settings as before, plus both
event forms and the import picker. Coming back leaves the caller standing
exactly as it was, with its calendar list already refreshed: switching a
calendar on writes VISIBLE, the provider notifies, the flow re-queries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:25:01 +02:00
d16c21f15a 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>
2026-07-25 22:24:50 +02:00
9 changed files with 264 additions and 28 deletions

View File

@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Settings → Calendars now says what is different about a calendar instead of
leaving you to guess. Ones you can only view — a subscribed calendar, a
calendar shared with you read-only — are marked **Read-only**. Ones whose
account isn't syncing events to this device are marked **Not synced**, moved
to the bottom of their account and left without a switch: none of their events
are on the device, so switching one on could not show you anything ([#76]).
- The calendar picker in the event form and in the .ics import screen now ends
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
those marks then explain why a calendar isn't offered ([#76]).
### Fixed
- Reminders now arrive for every calendar you have switched on. A calendar that
was hidden at system level — switched off in another calendar app, or never
@@ -1135,3 +1146,4 @@ automatically, with zero telemetry and no internet permission.
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76

View File

@@ -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 }

View File

@@ -415,6 +415,7 @@ fun CalendarHost(
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
)
}
}
@@ -434,6 +435,7 @@ fun CalendarHost(
editKey = null
detailKey = null
},
onManageCalendars = { showCalendars = true },
)
}
}
@@ -450,18 +452,6 @@ fun CalendarHost(
)
}
// Calendar manager — slides over Settings.
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
)
}
// Import flow for an opened/received .ics file. A single event routes
// into the create form (prefilled, for review); many open the picker.
importUri?.let { uri ->
@@ -469,6 +459,7 @@ fun CalendarHost(
uri = uri,
forceMany = importForceMany,
onClose = { importUri = null },
onManageCalendars = { showCalendars = true },
onOpenSingle = { form ->
importUri = null
importFormSource = ImportSource.File
@@ -483,6 +474,22 @@ fun CalendarHost(
initialFormSource = importFormSource,
onClose = { importForm = null },
onSaved = { importForm = null },
onManageCalendars = { showCalendars = true },
)
}
// Calendar manager — declared last so it covers every overlay that can
// open it: Settings, both event forms, and the .ics import picker (#76).
// Coming back from it leaves the caller exactly as it was, with the
// calendar list already refreshed by the provider's notification.
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
)
}
}

View File

@@ -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,

View File

@@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -42,12 +44,19 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
* account — with the calendars beneath it as a connected card, a colour chip on
* each and a check on the selected one. Emits into the caller's [ColumnScope]
* (a scrolling column), so the caller owns the surrounding chrome.
*
* The list holds event *targets* only, so a calendar that is switched off,
* read-only or managed is silently absent — which reads as a missing calendar
* rather than an excluded one (#76). [onManageCalendars], when given, adds the
* footer row that names the possible reasons and opens the calendar manager,
* where each row then says which one applies.
*/
@Composable
fun ColumnScope.CalendarPickerGroups(
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
val local = remember(calendars) { calendars.filter { it.isLocal } }
val syncedGroups = remember(calendars) {
@@ -75,6 +84,23 @@ fun ColumnScope.CalendarPickerGroups(
onSelect = onSelect,
)
}
if (onManageCalendars != null) {
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.calendar_picker_missing_title),
summary = stringResource(R.string.calendar_picker_missing_summary),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
trailing = {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onManageCalendars,
)
}
}
/** One account's category header (avatar + name) atop its selectable calendars. */

View File

@@ -193,6 +193,7 @@ fun EventEditScreen(
initialStartMinutes: Int? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
@@ -309,6 +310,7 @@ fun EventEditScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
onManageCalendars = onManageCalendars,
)
}
}
@@ -500,6 +502,7 @@ private fun EventEditContent(
state: EventEditUiState,
viewModel: EventEditViewModel,
modifier: Modifier = Modifier,
onManageCalendars: (() -> Unit)? = null,
) {
val form = state.form
val locale = currentLocale()
@@ -1122,6 +1125,10 @@ private fun EventEditContent(
viewModel.setCalendar(it)
showCalendarPicker = false
},
// Leaves the form and its picker standing underneath: the manager
// slides over, and a calendar switched on there is in the list on
// the way back (the provider notifies, the flow re-queries).
onManageCalendars = onManageCalendars,
onDismiss = { showCalendarPicker = false },
)
}
@@ -2277,6 +2284,7 @@ private fun CalendarPicker(
selectedId: Long?,
onSelect: (Long) -> Unit,
onDismiss: () -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
FullScreenPicker(
title = stringResource(R.string.event_detail_calendar),
@@ -2286,6 +2294,7 @@ private fun CalendarPicker(
calendars = calendars,
selectedId = selectedId,
onSelect = onSelect,
onManageCalendars = onManageCalendars,
)
}
}

View File

@@ -75,6 +75,7 @@ fun ImportScreen(
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null,
// Key the VM by the file uri. This screen has no nav backstack, so an
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
// across imports — its one-shot `load` guard would then show the *previous*
@@ -155,7 +156,12 @@ fun ImportScreen(
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
is ImportUiState.Many -> ManyContent(
state = s,
selected = selected,
onSelect = { selected = it },
onManageCalendars = onManageCalendars,
)
is ImportUiState.Done -> DoneContent(s, onClose)
}
}
@@ -163,7 +169,12 @@ fun ImportScreen(
}
@Composable
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
private fun ManyContent(
state: ImportUiState.Many,
selected: Long?,
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
// No writable calendar to import into — tell the user honestly.
if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
@@ -178,6 +189,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L
calendars = state.calendars,
selectedId = selected,
onSelect = onSelect,
onManageCalendars = onManageCalendars,
)
if (state.warnings.isNotEmpty()) {
Column(

View File

@@ -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>

View File

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