From e9d58d8239b3c1b044e3a37a58682265593c1743 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sat, 4 Jul 2026 11:09:45 +0200 Subject: [PATCH] feat(visibility): separate local quick-hide from server-synced banish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter-sheet eye was the only hide control on Android and it hard-synced to the server (sidebar_hidden/enabled) — so a quick, per-device hide leaked to the web and other devices. Now Android matches iOS' two-tier model: - Quick-hide (eye toggle) is device-local only (setCalendarHidden → hiddenKeys), no server call. - Banish ("permanently hide", new archive action per row) syncs sidebar_hidden to the server and moves the calendar into Settings; a new "Hidden calendars" section there re-enables it (server + auto-refetch). - reconcileCalendarVisibility now drives banishedKeys (not hiddenKeys) from the server's sidebar_hidden, so the reconcile never overwrites the local quick-hide. - Filter sheet lists calendars from the unfiltered cache (minus banished), so a locally hidden calendar still appears and can be toggled back on. Colours and reminders stay server-synced as before. iOS already had this model and is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../java/com/scarriffle/calendarr/ui/L10n.kt | 10 +++ .../calendarr/ui/accounts/AccountsScreen.kt | 48 ++++++++++++ .../ui/accounts/AccountsViewModel.kt | 7 ++ .../ui/calendar/CalendarFilterSheet.kt | 20 ++++- .../calendarr/ui/calendar/CalendarScreen.kt | 9 ++- .../ui/calendar/CalendarViewModel.kt | 73 ++++++++++++------- 6 files changed, 136 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 1d4b224..d720c05 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -123,6 +123,11 @@ object L10n { "filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden", "filter.button" to "Kalender ein-/ausblenden", "filter.sync_error" to "Synchronisierung fehlgeschlagen", + "filter.banish" to "Dauerhaft ausblenden", + "filter.banished_footer" to "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.", + "accounts.banished_header" to "Ausgeblendete Kalender", + "accounts.banished_unhide" to "Wieder einblenden", + "accounts.banished_unknown" to "Unbekannter Kalender", "caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL", "caldav.username" to "Benutzername", "caldav.password" to "Passwort", "caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", @@ -262,6 +267,11 @@ object L10n { "filter.show_all" to "Show all", "filter.hide_all" to "Hide all", "filter.button" to "Show/hide calendars", "filter.sync_error" to "Sync failed", + "filter.banish" to "Hide permanently", + "filter.banished_footer" to "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.", + "accounts.banished_header" to "Hidden calendars", + "accounts.banished_unhide" to "Show again", + "accounts.banished_unknown" to "Unknown calendar", "caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL", "caldav.username" to "Username", "caldav.password" to "Password", "caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt index c3bfa14..bb0b1e6 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt @@ -116,6 +116,26 @@ fun AccountsScreen( } } + // Calendars permanently hidden ("banished") on the server — offered for + // re-enabling. Derived from the loaded account lists' sidebar_hidden flags. + val banishedCals: List = buildList { + vm.caldav.forEach { acc -> + acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach { + add(BanishedCalendar("caldav", it.id, "${acc.name} – ${it.name}", it.color ?: acc.color)) + } + } + vm.google.forEach { acc -> + acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach { + add(BanishedCalendar("google", it.id, "${acc.email} – ${it.name}", it.color ?: "#4285f4")) + } + } + vm.homeAssistant.forEach { acc -> + acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach { + add(BanishedCalendar("homeassistant", it.id, "${acc.name} – ${it.name}", it.color ?: "#46bdc6")) + } + } + } + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Scaffold( topBar = { @@ -197,6 +217,14 @@ fun AccountsScreen( } } + // Banished (permanently hidden) calendars — re-enable here. + if (banishedCals.isNotEmpty()) { + item { SectionHeaderNoAdd(tr("accounts.banished_header")) } + items(banishedCals, key = { "b${it.source}${it.id}" }) { cal -> + BanishedCalendarRow(cal.label, cal.color) { vm.unbanishCalendar(cal.source, cal.id, onChanged) } + } + } + vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } } item { Spacer(Modifier.size(40.dp)) } } @@ -308,6 +336,26 @@ private fun ChildCalendarRow(name: String, color: String, onColor: () -> Unit) { } } +/** A permanently-hidden ("banished") calendar with a "show again" button. */ +private data class BanishedCalendar(val source: String, val id: Int, val label: String, val color: String) + +@Composable +private fun BanishedCalendarRow(name: String, color: String, onUnbanish: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ColorDot(color, editable = false, onClick = {}) + Text( + name, + modifier = Modifier.weight(1f).padding(start = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = onUnbanish) { Text(tr("accounts.banished_unhide")) } + } +} + /** A simple row (iCal) with editable colour + delete. */ @Composable private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt index 357faa9..e2b70a8 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt @@ -99,6 +99,13 @@ class AccountsViewModel @Inject constructor( fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) = mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) } + // ---- Banished (permanently hidden) calendars ---- + + /** Lift the server-side sidebar_hidden flag so a banished calendar reappears. + * `onChanged` triggers the calendar screen's reconcile + refetch. */ + fun unbanishCalendar(source: String, calendarId: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.setCalendarSidebarHidden(source, calendarId, hidden = false) } + // ---- Sharing ---- var shares by mutableStateOf>(emptyList()) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt index 7b9e749..c0a24e3 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.WarningAmber @@ -117,16 +118,33 @@ fun CalendarFilterSheet( tint = if (remDisabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, ) } + // Banish = permanently hide (syncs to the server); moves the + // calendar into Settings, distinct from the local quick-hide. + IconButton(onClick = { vm.setCalendarBanished(entry.key, banished = true) }) { + Icon( + Icons.Filled.Archive, + contentDescription = tr("filter.banish"), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Switch( checked = visible, onCheckedChange = { if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it) - else vm.setCalendarHiddenSynced(entry.key, entry.source, !it) + else vm.setCalendarHidden(entry.key, !it) }, ) } } + if (!groupMode && state.banishedKeys.isNotEmpty()) { + Text( + tr("filter.banished_footer"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } Box(Modifier.padding(bottom = 24.dp)) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index ba2c4de..163a982 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -226,7 +226,7 @@ fun CalendarScreen( if (showFilter) { CalendarFilterSheet( - events = remember(state.events, state.hiddenKeys) { allKnownCalendars(vm) }, + events = remember(state.events, state.banishedKeys) { allKnownCalendars(vm) }, vm = vm, onDismiss = { showFilter = false }, ) @@ -538,10 +538,11 @@ private fun GroupBanner(group: Group, onExit: () -> Unit) { } } -/** Distinct calendars currently present in the cache, for the filter sheet. */ +/** Distinct calendars in the cache for the filter sheet — from the UNFILTERED + * cache (minus banished) so a locally quick-hidden calendar still shows up and + * can be toggled back on. */ private fun allKnownCalendars(vm: CalendarViewModel): List { - val st = vm.state.value - return st.events + return vm.knownCalendars() .map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source) } .distinctBy { it.key } .sortedBy { it.name.lowercase() } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index cedac14..3df4b2c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -387,30 +387,34 @@ class CalendarViewModel @Inject constructor( } /** - * Reconcile the local hidden set with the server's per-calendar + * Reconcile the local **banished** set with the server's per-calendar * `sidebar_hidden` flags for external calendars (CalDAV / Google / HA). - * Returns `true` if the hidden set changed, so the caller can force a - * refetch — a calendar re-enabled on the web has NO events in the cache - * (the server excludes a hidden calendar's events entirely). Local / iCal - * hidden keys have no server flag and are left untouched. + * Returns `true` if the set changed, so the caller can force a refetch — a + * calendar re-enabled on the web has NO events in the cache (the server + * excludes a hidden calendar's events entirely). + * + * NOTE: This deliberately drives `banishedKeys`, NOT `hiddenKeys`. The + * quick-hide (`hiddenKeys`) is a device-local filter and must never be + * overwritten from the server; only the "banish / permanently hide" state + * maps to the server's `sidebar_hidden` (mirrors iOS). */ private suspend fun reconcileCalendarVisibility(): Boolean { val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()) val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()) val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()) - val hidden = settingsStore.hiddenCalendarKeys.toMutableSet() + val banished = settingsStore.banishedCalendarKeys.toMutableSet() fun apply(source: String, id: Int, serverHidden: Boolean) { val key = calendarKey(source, id.toString()) - if (serverHidden) hidden.add(key) else hidden.remove(key) + if (serverHidden) banished.add(key) else banished.remove(key) } caldav.forEach { acc -> acc.calendars?.forEach { apply("caldav", it.id, it.sidebarHidden) } } google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } } ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } } - if (hidden == settingsStore.hiddenCalendarKeys) return false - settingsStore.hiddenCalendarKeys = hidden - _state.update { it.copy(hiddenKeys = hidden) } + if (banished == settingsStore.banishedCalendarKeys) return false + settingsStore.banishedCalendarKeys = banished + _state.update { it.copy(banishedKeys = banished) } return true } @@ -429,28 +433,28 @@ class CalendarViewModel @Inject constructor( refreshFromCache() } - /** Like [setCalendarHidden] but also syncs server-side sidebar_hidden for external calendars. */ - fun setCalendarHiddenSynced(key: String, source: String, hidden: Boolean) { - setCalendarHidden(key, hidden) - val id = key.substringAfter(":").toIntOrNull() ?: return - if (source in listOf("caldav", "google", "homeassistant")) { - viewModelScope.launch { - runCatching { repository.setCalendarSidebarHidden(source, id, hidden) } - .onFailure { e -> - // The local toggle already applied; only the server write failed — - // surface it so the client doesn't silently diverge from server state. - _state.update { it.copy(error = e.message ?: "Fehler beim Speichern") } - } - } - } - } - fun setHiddenCalendars(keys: Set) { settingsStore.hiddenCalendarKeys = keys _state.update { it.copy(hiddenKeys = keys) } refreshFromCache() } + /** Distinct calendars present in the full cache, IGNORING the device-local + * quick-hide filter but excluding banished ones — for the filter sheet, so + * a locally hidden calendar still appears there and can be toggled back on. */ + fun knownCalendars(): List { + val banished = _state.value.banishedKeys + return allCachedEvents + .distinctBy { calendarKey(it.source, it.calendarId) } + .filter { calendarKey(it.source, it.calendarId) !in banished } + } + + /** + * Banish ("permanently hide") a calendar, or lift the banish. Unlike the + * quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for + * external calendars, matching iOS. Banishing also clears any local + * quick-hide flag for the same key (redundant once banished). + */ fun setCalendarBanished(key: String, banished: Boolean) { val nextBanished = _state.value.banishedKeys.toMutableSet().apply { if (banished) add(key) else remove(key) @@ -462,6 +466,23 @@ class CalendarViewModel @Inject constructor( settingsStore.hiddenCalendarKeys = nextHidden _state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) } refreshFromCache() + + val parts = key.split(":") + val source = parts.getOrNull(0) + val id = parts.getOrNull(1)?.toIntOrNull() + if (source != null && id != null && source in listOf("caldav", "google", "homeassistant")) { + viewModelScope.launch { + runCatching { repository.setCalendarSidebarHidden(source, id, banished) } + .onFailure { e -> _state.update { it.copy(error = e.message ?: "Fehler beim Speichern") } } + // Un-banishing re-enables the calendar on the server, but its + // events were excluded while hidden — force a refetch so they + // reappear without a manual sync. + if (!banished) { + invalidateCache() + initialLoad(reconcile = false) + } + } + } } // ---- Groups ----