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.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",

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) {
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) {

View File

@@ -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<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.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))
}
}

View File

@@ -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<CalendarFilterEntry> {
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() }

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).
* 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<String>) {
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<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) {
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 ----