feat(visibility): separate local quick-hide from server-synced banish

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 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-07-04 11:09:45 +02:00
parent ca4c875659
commit e9d58d8239
6 changed files with 136 additions and 31 deletions

View File

@@ -123,6 +123,11 @@ object L10n {
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden", "filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
"filter.button" to "Kalender ein-/ausblenden", "filter.button" to "Kalender ein-/ausblenden",
"filter.sync_error" to "Synchronisierung fehlgeschlagen", "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.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
"caldav.username" to "Benutzername", "caldav.password" to "Passwort", "caldav.username" to "Benutzername", "caldav.password" to "Passwort",
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", "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.show_all" to "Show all", "filter.hide_all" to "Hide all",
"filter.button" to "Show/hide calendars", "filter.button" to "Show/hide calendars",
"filter.sync_error" to "Sync failed", "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.display_name" to "Display name", "caldav.url" to "CalDAV URL",
"caldav.username" to "Username", "caldav.password" to "Password", "caldav.username" to "Username", "caldav.password" to "Password",
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account", "caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account",

View File

@@ -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<BanishedCalendar> = 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) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold( Scaffold(
topBar = { 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)) } } vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
item { Spacer(Modifier.size(40.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. */ /** A simple row (iCal) with editable colour + delete. */
@Composable @Composable
private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) { private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) {

View File

@@ -99,6 +99,13 @@ class AccountsViewModel @Inject constructor(
fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) = fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) } 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 ---- // ---- Sharing ----
var shares by mutableStateOf<List<CalendarShareEntry>>(emptyList()) var shares by mutableStateOf<List<CalendarShareEntry>>(emptyList())

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
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.filled.Archive
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.filled.WarningAmber
@@ -117,16 +118,33 @@ fun CalendarFilterSheet(
tint = if (remDisabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, 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( Switch(
checked = visible, checked = visible,
onCheckedChange = { onCheckedChange = {
if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it) 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)) Box(Modifier.padding(bottom = 24.dp))
} }
} }

View File

@@ -226,7 +226,7 @@ fun CalendarScreen(
if (showFilter) { if (showFilter) {
CalendarFilterSheet( CalendarFilterSheet(
events = remember(state.events, state.hiddenKeys) { allKnownCalendars(vm) }, events = remember(state.events, state.banishedKeys) { allKnownCalendars(vm) },
vm = vm, vm = vm,
onDismiss = { showFilter = false }, 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<CalendarFilterEntry> { private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
val st = vm.state.value return vm.knownCalendars()
return st.events
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source) } .map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source) }
.distinctBy { it.key } .distinctBy { it.key }
.sortedBy { it.name.lowercase() } .sortedBy { it.name.lowercase() }

View File

@@ -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). * `sidebar_hidden` flags for external calendars (CalDAV / Google / HA).
* Returns `true` if the hidden set changed, so the caller can force a * Returns `true` if the set changed, so the caller can force a refetch — a
* refetch — a calendar re-enabled on the web has NO events in the cache * calendar re-enabled on the web has NO events in the cache (the server
* (the server excludes a hidden calendar's events entirely). Local / iCal * excludes a hidden calendar's events entirely).
* hidden keys have no server flag and are left untouched. *
* 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 { private suspend fun reconcileCalendarVisibility(): Boolean {
val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()) val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()) val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
val ha = runCatching { repository.getHomeAssistantAccounts() }.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) { fun apply(source: String, id: Int, serverHidden: Boolean) {
val key = calendarKey(source, id.toString()) 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) } } caldav.forEach { acc -> acc.calendars?.forEach { apply("caldav", it.id, it.sidebarHidden) } }
google.forEach { acc -> acc.calendars?.forEach { apply("google", 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) } } ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } }
if (hidden == settingsStore.hiddenCalendarKeys) return false if (banished == settingsStore.banishedCalendarKeys) return false
settingsStore.hiddenCalendarKeys = hidden settingsStore.banishedCalendarKeys = banished
_state.update { it.copy(hiddenKeys = hidden) } _state.update { it.copy(banishedKeys = banished) }
return true return true
} }
@@ -429,28 +433,28 @@ class CalendarViewModel @Inject constructor(
refreshFromCache() 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<String>) { fun setHiddenCalendars(keys: Set<String>) {
settingsStore.hiddenCalendarKeys = keys settingsStore.hiddenCalendarKeys = keys
_state.update { it.copy(hiddenKeys = keys) } _state.update { it.copy(hiddenKeys = keys) }
refreshFromCache() 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<CalEvent> {
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) { fun setCalendarBanished(key: String, banished: Boolean) {
val nextBanished = _state.value.banishedKeys.toMutableSet().apply { val nextBanished = _state.value.banishedKeys.toMutableSet().apply {
if (banished) add(key) else remove(key) if (banished) add(key) else remove(key)
@@ -462,6 +466,23 @@ class CalendarViewModel @Inject constructor(
settingsStore.hiddenCalendarKeys = nextHidden settingsStore.hiddenCalendarKeys = nextHidden
_state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) } _state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) }
refreshFromCache() 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 ---- // ---- Groups ----