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>
This commit is contained in:
2026-07-25 22:25:01 +02:00
parent d16c21f15a
commit 5b64110be6
5 changed files with 80 additions and 14 deletions

View File

@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ### Fixed
- Reminders now arrive for every calendar you have switched on. A calendar that - 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 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 [#44]: https://codeberg.org/jlmakiola/calendula/issues/44
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70 [#70]: https://codeberg.org/jlmakiola/calendula/issues/70
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75 [#75]: https://codeberg.org/jlmakiola/calendula/issues/75
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76

View File

@@ -415,6 +415,7 @@ fun CalendarHost(
initialStartMinutes = createStartMinutes ?: heldCreateMinutes, initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
onClose = { createDateIso = null }, onClose = { createDateIso = null },
onSaved = { createDateIso = null }, onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
) )
} }
} }
@@ -434,6 +435,7 @@ fun CalendarHost(
editKey = null editKey = null
detailKey = 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 // Import flow for an opened/received .ics file. A single event routes
// into the create form (prefilled, for review); many open the picker. // into the create form (prefilled, for review); many open the picker.
importUri?.let { uri -> importUri?.let { uri ->
@@ -469,6 +459,7 @@ fun CalendarHost(
uri = uri, uri = uri,
forceMany = importForceMany, forceMany = importForceMany,
onClose = { importUri = null }, onClose = { importUri = null },
onManageCalendars = { showCalendars = true },
onOpenSingle = { form -> onOpenSingle = { form ->
importUri = null importUri = null
importFormSource = ImportSource.File importFormSource = ImportSource.File
@@ -483,6 +474,22 @@ fun CalendarHost(
initialFormSource = importFormSource, initialFormSource = importFormSource,
onClose = { importForm = null }, onClose = { importForm = null },
onSaved = { 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

@@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Cloud
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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 * 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] * each and a check on the selected one. Emits into the caller's [ColumnScope]
* (a scrolling column), so the caller owns the surrounding chrome. * (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 @Composable
fun ColumnScope.CalendarPickerGroups( fun ColumnScope.CalendarPickerGroups(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
selectedId: Long?, selectedId: Long?,
onSelect: (Long) -> Unit, onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) { ) {
val local = remember(calendars) { calendars.filter { it.isLocal } } val local = remember(calendars) { calendars.filter { it.isLocal } }
val syncedGroups = remember(calendars) { val syncedGroups = remember(calendars) {
@@ -75,6 +84,23 @@ fun ColumnScope.CalendarPickerGroups(
onSelect = onSelect, 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. */ /** One account's category header (avatar + name) atop its selectable calendars. */

View File

@@ -193,6 +193,7 @@ fun EventEditScreen(
initialStartMinutes: Int? = null, initialStartMinutes: Int? = null,
initialForm: EventForm? = null, initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File, initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
viewModel: EventEditViewModel = hiltViewModel(), viewModel: EventEditViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(initialDateIso, editKey, initialForm) { LaunchedEffect(initialDateIso, editKey, initialForm) {
@@ -309,6 +310,7 @@ fun EventEditScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding),
onManageCalendars = onManageCalendars,
) )
} }
} }
@@ -500,6 +502,7 @@ private fun EventEditContent(
state: EventEditUiState, state: EventEditUiState,
viewModel: EventEditViewModel, viewModel: EventEditViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onManageCalendars: (() -> Unit)? = null,
) { ) {
val form = state.form val form = state.form
val locale = currentLocale() val locale = currentLocale()
@@ -1122,6 +1125,10 @@ private fun EventEditContent(
viewModel.setCalendar(it) viewModel.setCalendar(it)
showCalendarPicker = false 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 }, onDismiss = { showCalendarPicker = false },
) )
} }
@@ -2277,6 +2284,7 @@ private fun CalendarPicker(
selectedId: Long?, selectedId: Long?,
onSelect: (Long) -> Unit, onSelect: (Long) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onManageCalendars: (() -> Unit)? = null,
) { ) {
FullScreenPicker( FullScreenPicker(
title = stringResource(R.string.event_detail_calendar), title = stringResource(R.string.event_detail_calendar),
@@ -2286,6 +2294,7 @@ private fun CalendarPicker(
calendars = calendars, calendars = calendars,
selectedId = selectedId, selectedId = selectedId,
onSelect = onSelect, onSelect = onSelect,
onManageCalendars = onManageCalendars,
) )
} }
} }

View File

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