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