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:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user