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:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
Reference in New Issue
Block a user