feat(calendars): explain a calendar's state, and why one is missing from the picker (#76, #78) (!101)

Follow-up to #97, on the same release line. After the visibility fix a calendar can be absent from the pickers for three different reasons the app knows and never said; this closes that gap.

**Settings → Calendars names the state** (#76, #78): read-only calendars are marked `Read-only`; ones whose account isn't syncing events to this device are marked `Not synced`, sorted to the bottom of their account, dimmed, and left **without a switch** — visibility can't reveal events that aren't on the device, so the control did nothing.

**Both pickers end in a "Missing a calendar?" row** (#76) opening the calendar manager, where those labels then say which reason applies. The manager overlay moved to the end of the host's overlay stack so it covers every surface that can open it (Settings, both event forms, the import picker).

Review notes:

- **Supporting text, not chips.** A row can carry several states at once next to a live switch; M3 supporting text composes there, static badges don't (and a non-interactive chip reads as a broken button).
- **`sync_events = 0` counts as "not synced" for account-backed calendars only.** Nothing syncs a device-local calendar by definition, and another app's local calendar can hold real events at 0 — the unsoundness that made the first #75 migration guard wrong. Covered by a test.
- **Excluded calendars stay out of the pickers** rather than being listed unpickable — a handful of read-only subscriptions would crowd out the ones you can actually choose.

541 JVM tests green (6 new), lint clean, check_translations clean. **On-device review owed**; the three new string keys need the Weblate backfill.

Reviewed-on: #101
This commit was merged in pull request #101.
This commit is contained in:
2026-07-26 17:17:02 +00:00
parent bf6415c023
commit 8e2109d073
14 changed files with 498 additions and 53 deletions

View File

@@ -7,6 +7,28 @@ 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** ([#76]).
- Calendars your device isn't syncing 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 the switch they used to have could not have shown you anything
— the calendar simply looked broken. They are no longer offered when you pick
a calendar for a new or an imported event either: an event saved there would
never reach the account. Whether an account syncs a calendar stays that
account's own app's decision ([#78]).
- The birthday and anniversary calendars Calendula fills from your contacts are
marked **Filled from your contacts**, which is why they can't be picked for a
new event: anything you put there would be removed again on the next sync.
Deleting one is held back while special dates are switched on — Calendula
would simply create it again — and the calendar's editor says so; turn the
feature off under Settings → Special dates and the delete works as usual
([#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 +1157,5 @@ 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
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78

View File

@@ -67,6 +67,14 @@ class CalendarVisibilityReconciler @Inject constructor(
// drain and nothing left to decide, so don't pay for the query. // drain and nothing left to decide, so don't pay for the query.
if (pending.isEmpty() && noticeSettled) return@withContext if (pending.isEmpty() && noticeSettled) return@withContext
val calendars = dataSource.calendars() val calendars = dataSource.calendars()
// An empty read means "couldn't read", not "no calendars": the data
// source turns a null cursor — a provider momentarily unavailable —
// into an empty list. Both decisions below are one-way, so taking
// that reading as the truth would drop the whole pending set without
// ever writing VISIBLE = 0 (switching the user's calendars back on,
// events and reminders with them) and settle the notice as "nothing
// to explain". Leave both to the next run.
if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending)) settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) { if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext return@withContext

View File

@@ -0,0 +1,76 @@
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 {
/**
* A special-dates mirror the app fills from contacts. Writable and visible,
* yet no event target: anything authored here is deleted by the next sync,
* which is why it is the one exclusion with nothing else to give it away.
*/
MANAGED,
/** 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
/**
* Whether this calendar can be offered as a target for a new or imported event.
* The one predicate behind both pickers, so the states [CalendarStateLabel]
* names on a manager row are exactly the states that keep a calendar out of
* them (#76):
*
* - read-only has nowhere to write;
* - switched off would hide the event the moment it was saved;
* - a managed mirror has the next contact sync delete it;
* - a non-syncing one never carries the event up to the account, and
* `CalendarProvider2` wipes the calendar's rows outright when the
* subscription is switched back on — a saved event is a dead end either way.
*
* This is the test for *targets*. An event already living in an excluded
* calendar keeps it; the editor adds that calendar back to its picker.
*/
val CalendarSource.isEventTarget: Boolean
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
/** Every state worth naming on this calendar's row, in reading order. */
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
if (isManaged) add(CalendarStateLabel.MANAGED)
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, 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,27 @@ 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 },
// The manager opens the import too (restore from backup), and
// that way round it has to step aside: declared above the import
// overlays, it would otherwise cover the screen it just asked
// for. Closing it hands the user back to whatever opened the
// manager once the import is done.
onImport = { importUri = it; importForceMany = true; showCalendars = false },
) )
} }
} }

View File

@@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@@ -91,6 +92,12 @@ 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.isEventTarget
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
@@ -139,6 +146,7 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
@@ -157,6 +165,7 @@ fun CalendarsScreen(
initialName = editing?.displayName.orEmpty(), initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CalendarColorPalette.all.first(), initialColor = editing?.color ?: CalendarColorPalette.all.first(),
initialDescription = editing?.description.orEmpty(), initialDescription = editing?.description.orEmpty(),
deleteLocked = editing != null && editing.id in deleteLockedIds,
onSave = { name, color, description -> onSave = { name, color, description ->
val id = editorId val id = editorId
if (id == null || id == NEW_CALENDAR_ID) { if (id == null || id == NEW_CALENDAR_ID) {
@@ -305,7 +314,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,
@@ -327,9 +336,9 @@ private fun CalendarsList(
// safety net. Offered only when there is something exportable: the user's // safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count). // own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged } val exportable = local.filter { it.canModifyContents && !it.isManaged }
// Restore/import can target any writable, non-managed calendar (local or // Restore/import can target any calendar the import picker would offer
// synced), so its availability is broader than export's. // (local or synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged } val canImport = (local + synced).any { it.isEventTarget }
if (exportable.isNotEmpty()) { if (exportable.isNotEmpty()) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header)) SectionHeader(stringResource(R.string.calendars_backup_header))
@@ -412,7 +421,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 +445,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
}, },
) )
} }
@@ -557,6 +582,7 @@ private fun CalendarEditor(
onSave: (name: String, color: Int, description: String?) -> Unit, onSave: (name: String, color: Int, description: String?) -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
deleteLocked: Boolean = false,
) { ) {
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) } var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) } var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
@@ -590,11 +616,23 @@ private fun CalendarEditor(
}, },
actions = { actions = {
if (!isNew) { if (!isNew) {
IconButton(onClick = { confirmDelete = true }) { // Kept in place while the special-dates sync owns this
// calendar, rather than hidden: the button is where you
// expect it, disabled, with the card below saying why —
// and it comes back to life the moment the feature is
// off, when the delete would actually stick.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
) {
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete), contentDescription = stringResource(R.string.event_detail_delete),
tint = MaterialTheme.colorScheme.error, tint = if (deleteLocked) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.error
},
) )
} }
} }
@@ -623,6 +661,19 @@ private fun CalendarEditor(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (deleteLocked) {
EditorCard(
icon = Icons.Default.Info,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) { EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
InlineTextField( InlineTextField(
value = name, value = name,
@@ -694,6 +745,27 @@ 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.MANAGED -> R.string.calendars_state_managed
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,

View File

@@ -74,6 +74,33 @@ class CalendarsViewModel @Inject constructor(
initialValue = AutoBackupUiState(), initialValue = AutoBackupUiState(),
) )
/**
* Managed special-dates calendars whose deletion would not stick. While the
* feature is on, the sync owns every mirror: it recreates a missing one for
* an enabled type on the next pass and deletes the leftover of a disabled
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
* delete would appear to work and then undo itself. Turning special dates
* off empties this set, and deleting a leftover mirror is a real delete from
* then on.
*
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
* ids, which are only rewritten on the next sync pass — a preferences loss
* would otherwise unlock a live mirror until then.
*/
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
calendars,
settingsPrefs.specialDatesEnabled,
) { sources, enabled ->
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
private val _error = MutableStateFlow(false) private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow() val error: StateFlow<Boolean> = _error.asStateFlow()

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

@@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -193,6 +195,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 +312,7 @@ fun EventEditScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding),
onManageCalendars = onManageCalendars,
) )
} }
} }
@@ -500,6 +504,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()
@@ -509,6 +514,10 @@ private fun EventEditContent(
// they're locked here; everything else (reminders, location, notes) is the // they're locked here; everything else (reminders, location, notes) is the
// user's to edit. // user's to edit.
val locked = state.isManaged val locked = state.isManaged
// Read in the form's own window, not the picker's: the field holding focus
// lives here, so this is the controller that can put its keyboard away.
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
var picker by remember { mutableStateOf<PickerTarget?>(null) } var picker by remember { mutableStateOf<PickerTarget?>(null) }
var showCalendarPicker by rememberSaveable { mutableStateOf(false) } var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -1114,6 +1123,17 @@ private fun EventEditContent(
null -> Unit null -> Unit
} }
// A full-screen picker over the form is a change of place, so the form's
// keyboard has no business following it there — least of all onto the
// calendar manager, which the picker can hand off to. The form's own field
// keeps its text; only focus and the IME go.
LaunchedEffect(showCalendarPicker) {
if (showCalendarPicker) {
focusManager.clearFocus(force = true)
keyboardController?.hide()
}
}
if (showCalendarPicker) { if (showCalendarPicker) {
CalendarPicker( CalendarPicker(
calendars = state.calendars, calendars = state.calendars,
@@ -1122,6 +1142,17 @@ private fun EventEditContent(
viewModel.setCalendar(it) viewModel.setCalendar(it)
showCalendarPicker = false showCalendarPicker = false
}, },
// Close the picker on the way out. It is a Compose Dialog — its own
// window, always above the activity's content — so the manager would
// otherwise open behind it and the tap would look dead. The form
// stays standing underneath, its calendar row one tap from a picker
// that re-queries on open.
onManageCalendars = onManageCalendars?.let { openManager ->
{
showCalendarPicker = false
openManager()
}
},
onDismiss = { showCalendarPicker = false }, onDismiss = { showCalendarPicker = false },
) )
} }
@@ -2277,6 +2308,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 +2318,7 @@ private fun CalendarPicker(
calendars = calendars, calendars = calendars,
selectedId = selectedId, selectedId = selectedId,
onSelect = onSelect, onSelect = onSelect,
onManageCalendars = onManageCalendars,
) )
} }
} }

View File

@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.populatedFields import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot import de.jeanlucmakiola.calendula.domain.toEditSnapshot
@@ -177,19 +178,16 @@ class EventEditViewModel @Inject constructor(
repository.calendars().catch { emit(emptyList()) } repository.calendars().catch { emit(emptyList()) }
/** /**
* Writable calendars — the only valid event targets. Calendars switched off * The calendars a new event can be saved to ([isEventTarget]): writable,
* in Settings → Calendars are excluded, so you can't create into one you've * switched on, not a contact-filled mirror, not a non-syncing subscription.
* turned off; a last-used preselect landing on a now-off calendar falls back * A last-used preselect landing on an excluded calendar falls back to the
* to the first remaining writable one (handled by [resolvedCalendarId] and * first remaining one (handled by [resolvedCalendarId] and [state]).
* [state]). Managed special-dates calendars are excluded too: their events
* are owned by the contact sync, which would delete any user event created
* there.
* *
* This is the list of *targets*. An event already living in an excluded * This is the list of *targets*. An event already living in an excluded
* calendar keeps it — [state] adds it back to the picker. * calendar keeps it — [state] adds it back to the picker.
*/ */
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars -> private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
calendars.filter { it.canModifyContents && it.isVisibleInSystem && !it.isManaged } calendars.filter { it.isEventTarget }
} }
/** The target calendar id, resolved exactly as the form shows it. */ /** The target calendar id, resolved exactly as the form shows it. */

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,10 +169,24 @@ fun ImportScreen(
} }
@Composable @Composable
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) { private fun ManyContent(
// No writable calendar to import into — tell the user honestly. state: ImportUiState.Many,
selected: Long?,
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
// No calendar to import into — tell the user honestly, and carry the same
// way out the picker's footer offers below. This is the state that footer
// exists for: every writable calendar being switched off, read-only or
// contact-filled is exactly what empties this list (#76).
if (state.calendars.isEmpty()) { if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null) CenteredMessage(
message = stringResource(R.string.import_no_calendar),
onClose = null,
actionLabel = stringResource(R.string.settings_manage_calendars)
.takeIf { onManageCalendars != null },
onAction = onManageCalendars,
)
return return
} }
@@ -178,6 +198,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(
@@ -333,7 +354,12 @@ private fun WarningText(warning: IcsParseWarning) {
} }
@Composable @Composable
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) { private fun CenteredMessage(
message: String,
onClose: (() -> Unit)?,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column( Column(
Modifier.padding(24.dp), Modifier.padding(24.dp),
@@ -344,6 +370,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
if (onClose != null) { if (onClose != null) {
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) } Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
} }
if (actionLabel != null && onAction != null) {
Button(onClick = onAction) { Text(actionLabel) }
}
} }
} }
} }

View File

@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.domain.ics.IcsParser import de.jeanlucmakiola.calendula.domain.ics.IcsParser
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -85,18 +86,14 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings, warnings = parsed.warnings,
) )
else -> { else -> {
// A calendar switched off in Settings → Calendars is off // The same targets the event form offers ([isEventTarget]):
// everywhere, so it can't be an import target — exclude it // an import is a bulk create, so a calendar that can't hold
// alongside the read-only ones. Managed special-dates // one event can't hold thirty.
// calendars are contact-derived and editor-locked, so
// they're not a valid destination either.
ImportUiState.Many( ImportUiState.Many(
events = parsed.events, events = parsed.events,
warnings = parsed.warnings, warnings = parsed.warnings,
calendars = repository.calendars().first() calendars = repository.calendars().first()
.filter { .filter { it.isEventTarget },
it.canModifyContents && !it.isManaged && it.isVisibleInSystem
},
) )
} }
} }

View File

@@ -477,6 +477,13 @@
<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, read-only, or filled from your contacts — 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_state_managed">Filled from your contacts</string>
<string name="calendars_managed_delete_locked">This calendar is filled from your contacts, so Calendula would create it again on the next sync. Turn special dates off under Settings → Special dates to delete it.</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>

View File

@@ -0,0 +1,110 @@
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,
managed: 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,
isManaged = managed,
)
@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 managed special-dates mirror is labelled although it is writable`() {
// Writable, visible, syncing — nothing else on the row would hint at why
// it can't be picked as an event target.
val calendar = cal(local = true, managed = true)
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.MANAGED)
assertThat(calendar.hasVisibilitySwitch).isTrue()
}
@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 `every named state keeps a calendar out of the pickers`() {
// The labels and the picker exclusion are the same set, stated twice —
// a labelled row the pickers still offered would make the footer's
// "manage your calendars to see why" a lie (#76).
assertThat(cal().isEventTarget).isTrue()
listOf(
cal(writable = false),
cal(syncsEvents = false),
cal(local = true, managed = true),
).forEach { calendar ->
assertThat(calendar.stateLabels()).isNotEmpty()
assertThat(calendar.isEventTarget).isFalse()
}
}
@Test
fun `a switched-off calendar is no target although it carries no label`() {
// The switch is right there on the row, so the state speaks for itself.
val calendar = cal().copy(isVisibleInSystem = false)
assertThat(calendar.stateLabels()).isEmpty()
assertThat(calendar.isEventTarget).isFalse()
}
@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()
}
}

View File

@@ -45,9 +45,14 @@ class EventEditViewModelTest {
private val beginMillis = 1_781_164_800_000L private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L private val endMillis = beginMillis + 3_600_000L
private fun cal(id: Long, visible: Boolean = true): CalendarSource = CalendarSource( private fun cal(
id: Long,
visible: Boolean = true,
syncsEvents: Boolean = true,
): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL", id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true, color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
syncsEvents = syncsEvents,
) )
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail( private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
@@ -108,6 +113,27 @@ class EventEditViewModelTest {
job.cancel() job.cancel()
} }
@Test
fun `a calendar whose account is not synced to this device is not a target`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// Writable and switched on, but the account keeps its events off the
// device: nothing saved here ever reaches it, and the provider drops the
// rows when the subscription comes back (#76).
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, syncsEvents = false))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
job.cancel()
}
@Test @Test
fun `editing an event in a switched-off calendar keeps it in the picker`( fun `editing an event in a switched-off calendar keeps it in the picker`(
@TempDir tempDir: Path, @TempDir tempDir: Path,