diff --git a/CHANGELOG.md b/CHANGELOG.md index aa1720a..f54a13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the device, so the switch they used to have could not have shown you anything — the calendar simply looked broken. 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]). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt index ed41e7e..a90912b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt @@ -6,6 +6,13 @@ package de.jeanlucmakiola.calendula.domain * 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, @@ -36,6 +43,7 @@ val CalendarSource.hasVisibilitySwitch: Boolean /** Every state worth naming on this calendar's row, in reading order. */ fun CalendarSource.stateLabels(): List = buildList { + if (isManaged) add(CalendarStateLabel.MANAGED) if (!canModifyContents) add(CalendarStateLabel.READ_ONLY) if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index b213fbc..b755318 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud 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.FileUpload import androidx.compose.material.icons.filled.MoreVert @@ -144,6 +145,7 @@ fun CalendarsScreen( viewModel: CalendarsViewModel = hiltViewModel(), ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() + val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() @@ -162,6 +164,7 @@ fun CalendarsScreen( initialName = editing?.displayName.orEmpty(), initialColor = editing?.color ?: CalendarColorPalette.all.first(), initialDescription = editing?.description.orEmpty(), + deleteLocked = editing != null && editing.id in deleteLockedIds, onSave = { name, color, description -> val id = editorId if (id == null || id == NEW_CALENDAR_ID) { @@ -578,6 +581,7 @@ private fun CalendarEditor( onSave: (name: String, color: Int, description: String?) -> Unit, onDelete: () -> Unit, onClose: () -> Unit, + deleteLocked: Boolean = false, ) { var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) } var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) } @@ -611,11 +615,23 @@ private fun CalendarEditor( }, actions = { 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( Icons.Default.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 + }, ) } } @@ -644,6 +660,19 @@ private fun CalendarEditor( .padding(horizontal = 16.dp, vertical = 8.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)) { InlineTextField( value = name, @@ -726,6 +755,7 @@ 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 }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 1635054..4d25bae 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -74,6 +74,27 @@ class CalendarsViewModel @Inject constructor( initialValue = AutoBackupUiState(), ) + /** + * Managed special-dates calendars whose deletion would not stick. While its + * type is enabled, the sync recreates a missing mirror on the next pass + * (`SpecialDatesSyncEngine.reconcileCalendars`), so 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. + */ + val deleteLockedCalendarIds: StateFlow> = combine( + settingsPrefs.specialDatesEnabled, + settingsPrefs.specialDatesTypes, + settingsPrefs.specialDatesCalendars, + ) { enabled, types, byType -> + if (!enabled) emptySet() else types.mapNotNull { byType[it] }.toSet() + } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptySet(), + ) + private val _error = MutableStateFlow(false) val error: StateFlow = _error.asStateFlow() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a644c3..3e24629 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -479,9 +479,11 @@ 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. Missing a calendar? - It may be switched off on this device, or read-only — manage your calendars here. + It may be switched off, read-only, or filled from your contacts — manage your calendars here. Read-only Not synced to this device + Filled from your contacts + 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. Synced calendars These come from accounts on your device. Create and edit them in their own app. Manage in app diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt index e7c9a97..92ed263 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt @@ -11,6 +11,7 @@ class CalendarRowStateTest { writable: Boolean = true, syncsEvents: Boolean = true, local: Boolean = false, + managed: Boolean = false, ) = CalendarSource( id = id, displayName = name, @@ -21,6 +22,7 @@ class CalendarRowStateTest { canModifyContents = writable, isLocal = local, syncsEvents = syncsEvents, + isManaged = managed, ) @Test @@ -49,6 +51,15 @@ class CalendarRowStateTest { .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