feat(calendars): name the contact-filled calendars, and hold their delete (#76)
The special-dates mirrors were the last unexplained exclusion: writable, visible, syncing, and still not offered as an event target, because their events belong to the contact sync and anything authored there is removed on the next pass. Their rows now say "Filled from your contacts", and the pickers' footer names that third reason alongside switched-off and read-only. Their delete stays where it is rather than disappearing, but is held while special dates are on: reconcileCalendars recreates a missing mirror for an enabled type on the very next sync, so the delete would look like it worked and then quietly undo itself. The button dims, an info card in the editor says why and points at Settings → Special dates, and turning the feature off gives the delete back — at which point a leftover mirror really is the user's to remove. The lock is derived from the enabled types' stored calendar ids, which is what the sync itself reconciles against: a mirror whose type is switched off is already an orphan the next sync would delete anyway, so it stays deletable by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 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
|
— the calendar simply looked broken. Whether an account syncs a calendar stays
|
||||||
that account's own app's decision ([#78]).
|
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
|
- 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
|
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||||
those marks then explain why a calendar isn't offered ([#76]).
|
those marks then explain why a calendar isn't offered ([#76]).
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ package de.jeanlucmakiola.calendula.domain
|
|||||||
* something the app knows and used to keep to itself (#76).
|
* something the app knows and used to keep to itself (#76).
|
||||||
*/
|
*/
|
||||||
enum class CalendarStateLabel {
|
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. */
|
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
||||||
READ_ONLY,
|
READ_ONLY,
|
||||||
|
|
||||||
@@ -36,6 +43,7 @@ val CalendarSource.hasVisibilitySwitch: Boolean
|
|||||||
|
|
||||||
/** Every state worth naming on this calendar's row, in reading order. */
|
/** Every state worth naming on this calendar's row, in reading order. */
|
||||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||||
|
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||||
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
||||||
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -144,6 +145,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()
|
||||||
@@ -162,6 +164,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) {
|
||||||
@@ -578,6 +581,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) }
|
||||||
@@ -611,11 +615,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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,6 +660,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,
|
||||||
@@ -726,6 +755,7 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
|
|||||||
val states = calendar.stateLabels().map { label ->
|
val states = calendar.stateLabels().map { label ->
|
||||||
stringResource(
|
stringResource(
|
||||||
when (label) {
|
when (label) {
|
||||||
|
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
||||||
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
||||||
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,6 +74,27 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = AutoBackupUiState(),
|
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<Set<Long>> = 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)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -479,9 +479,11 @@
|
|||||||
<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. -->
|
<!-- 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_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="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_read_only">Read-only</string>
|
||||||
<string name="calendars_state_not_synced">Not synced to this device</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>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class CalendarRowStateTest {
|
|||||||
writable: Boolean = true,
|
writable: Boolean = true,
|
||||||
syncsEvents: Boolean = true,
|
syncsEvents: Boolean = true,
|
||||||
local: Boolean = false,
|
local: Boolean = false,
|
||||||
|
managed: Boolean = false,
|
||||||
) = CalendarSource(
|
) = CalendarSource(
|
||||||
id = id,
|
id = id,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
@@ -21,6 +22,7 @@ class CalendarRowStateTest {
|
|||||||
canModifyContents = writable,
|
canModifyContents = writable,
|
||||||
isLocal = local,
|
isLocal = local,
|
||||||
syncsEvents = syncsEvents,
|
syncsEvents = syncsEvents,
|
||||||
|
isManaged = managed,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -49,6 +51,15 @@ class CalendarRowStateTest {
|
|||||||
.inOrder()
|
.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
|
@Test
|
||||||
fun `a local calendar is never called not-synced`() {
|
fun `a local calendar is never called not-synced`() {
|
||||||
// Nothing syncs a device-local calendar, so sync_events says nothing
|
// Nothing syncs a device-local calendar, so sync_events says nothing
|
||||||
|
|||||||
Reference in New Issue
Block a user