Settings sync, calendar visibility sync, event refresh & week-view fixes

- Add two-way settings sync (SettingsSync) with toggle, app-start/foreground/
  10-min pull and debounced push; server wins; view/week-start/dim-past always
  sync. Wire previously-ignored settings (hour height, contrasts, week start,
  default view, dim past) into the actual UI.
- Make AppSettings decoding resilient (decodeIfPresent) so getSettings no longer
  fails on iOS-only fields the server omits; keep text/bg/line colors local-only;
  month divider/label colors now sync.
- Auto-refresh after create/edit (cache-busting) and optimistic removal on
  delete; switch delete confirm to a centered alert. Add HA event deletion.
- Calendar visibility: fix inverted hide/show toggle; normalize calendar keys so
  local filtering works for all sources; sync banish with server sidebar_hidden
  (CalDAV/Google/HA), refetch on un-banish.
- Manual "sync with server" button in the menu.
- Upcoming widget shows next 5 days (renamed).
- Week/Day view: route multi-day timed events to the all-day strip so they no
  longer render as a full-height block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-27 20:44:14 +02:00
parent 07a9e9eb7f
commit 4125bfc728
16 changed files with 616 additions and 156 deletions

View File

@@ -34,6 +34,33 @@ struct AppSettings: Codable {
case backgroundColor = "background_color"
case lineColor = "line_color"
}
init() {}
/// Resilient decoding: the server only stores a subset of these fields
/// (e.g. it has no `text_color`/`background_color`/`line_color`, which are
/// iOS-only). Using `decodeIfPresent` with the property defaults means a
/// missing key no longer aborts the whole decode otherwise the entire
/// settings sync silently breaks.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AppSettings()
defaultView = try c.decodeIfPresent(String.self, forKey: .defaultView) ?? d.defaultView
weekStartDay = try c.decodeIfPresent(String.self, forKey: .weekStartDay) ?? d.weekStartDay
primaryColor = try c.decodeIfPresent(String.self, forKey: .primaryColor) ?? d.primaryColor
accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) ?? d.accentColor
todayColor = try c.decodeIfPresent(String.self, forKey: .todayColor) ?? d.todayColor
dimPastEvents = try c.decodeIfPresent(Bool.self, forKey: .dimPastEvents) ?? d.dimPastEvents
textContrast = try c.decodeIfPresent(Int.self, forKey: .textContrast) ?? d.textContrast
lineContrast = try c.decodeIfPresent(Int.self, forKey: .lineContrast) ?? d.lineContrast
hourHeight = try c.decodeIfPresent(Int.self, forKey: .hourHeight) ?? d.hourHeight
language = try c.decodeIfPresent(String.self, forKey: .language) ?? d.language
monthDividerColor = try c.decodeIfPresent(String.self, forKey: .monthDividerColor) ?? d.monthDividerColor
monthLabelColor = try c.decodeIfPresent(String.self, forKey: .monthLabelColor) ?? d.monthLabelColor
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
}
}
struct CalDAVAccount: Codable, Identifiable {
@@ -124,10 +151,22 @@ struct HACalendar: Codable, Identifiable {
var entityId: String
var color: String?
var enabled: Bool
var sidebarHidden: Bool
enum CodingKeys: String, CodingKey {
case id, name, color, enabled
case entityId = "entity_id"
case sidebarHidden = "sidebar_hidden"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
entityId = try c.decodeIfPresent(String.self, forKey: .entityId) ?? ""
color = try c.decodeIfPresent(String.self, forKey: .color)
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
}
}

View File

@@ -6,6 +6,11 @@ extension Notification.Name {
/// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
/// listens for this in `CalendarHostView` and refreshes its filter.
static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
/// Posted when the user taps the manual "sync with server" button in the
/// menu. `CalendarHostView` responds by invalidating the cache and
/// re-fetching events from the server.
static let manualSyncRequested = Notification.Name("manualSyncRequested")
}
enum CalViewType: String, CaseIterable {
@@ -107,7 +112,16 @@ class CalendarStore {
}
static func calendarKey(source: String, calendarId: String) -> String {
"\(source):\(calendarId)"
// The events API returns `calendar_id` inconsistently: a raw numeric for
// CalDAV, but "<source>-<id>" for local / ical / google / homeassistant
// (e.g. "local-3", "google-5"). The filter UI keys off the numeric DB id,
// so strip any leading "<source>-" prefix to make event keys and filter
// keys comparable otherwise local hiding/banishing silently does nothing
// for those sources.
var id = calendarId
let prefix = "\(source)-"
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
return "\(source):\(id)"
}
// MARK: Banished-calendar persistence
@@ -149,6 +163,18 @@ class CalendarStore {
publishWidgetSnapshot()
}
/// Replace the whole banished set (used when reconciling with the server's
/// `sidebar_hidden` flags). Persists, notifies, refreshes.
func setBanishedCalendars(_ keys: Set<String>) {
guard keys != banishedCalendarKeys else { return }
banishedCalendarKeys = keys
Self.saveBanishedKeys(keys)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
/// Re-read the banished set from UserDefaults called when an external
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
func syncBanishedFromDefaults() {
@@ -158,6 +184,17 @@ class CalendarStore {
publishWidgetSnapshot()
}
/// Split a `"source:calendarId"` key back into its parts.
static func parseCalendarKey(_ key: String) -> (source: String, id: Int)? {
guard let colon = key.firstIndex(of: ":") else { return nil }
let source = String(key[..<colon])
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
return (source, id)
}
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant"]
var userCalendar: Calendar {
var cal = Calendar.current
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
@@ -186,11 +223,23 @@ class CalendarStore {
}
}
/// Optimistically drop a just-deleted event from the cache so it disappears
/// from the UI immediately, without waiting for a server round-trip (HA
/// deletes can lag several seconds, and an immediate refetch could even
/// re-add it before the source propagated the deletion).
func removeCachedEvent(id: String) {
allCachedEvents.removeAll { $0.id == id }
events.removeAll { $0.id == id }
publishWidgetSnapshot()
}
// MARK: Network loading
/// Load events for a specific range skips network if already cached.
func loadEvents(api: CalendarrAPI, start: Date, end: Date) async {
if isCached(start: start, end: end) {
/// Load events for a specific range. Skips the network if already cached,
/// unless `force` is set (used after create/edit to pull fresh server data
/// for the visible range, bypassing the cache).
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
if !force, isCached(start: start, end: end) {
refreshFromCache(start: start, end: end)
return
}

View File

@@ -64,6 +64,8 @@ private let strings: [String: [String: String]] = [
"menu.server": "Server",
"menu.logout": "Abmelden",
"menu.admin": "Admin",
"menu.sync": "Mit Server synchronisieren",
"menu.sync.section": "Synchronisierung",
// Settings chrome
"settings.title": "Darstellung",
@@ -76,6 +78,9 @@ private let strings: [String: [String: String]] = [
"settings.liquidglass": "Liquid Glass",
"settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste",
"settings.liquidglass.footer": "Änderung wirkt sofort kein Neustart nötig.",
"settings.sync": "Einstellungen synchronisieren",
"settings.sync.desc": "Darstellung mit dem Server abgleichen",
"settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert auch wenn der Schalter aus ist.",
"settings.cache.header": "Vorladen",
"settings.cache.title": "Vorladen",
@@ -320,6 +325,8 @@ private let strings: [String: [String: String]] = [
"menu.server": "Server",
"menu.logout": "Sign out",
"menu.admin": "Admin",
"menu.sync": "Sync with server",
"menu.sync.section": "Synchronization",
"settings.title": "Appearance",
"settings.loading": "Loading settings…",
@@ -330,6 +337,9 @@ private let strings: [String: [String: String]] = [
"settings.liquidglass": "Liquid Glass",
"settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar",
"settings.liquidglass.footer": "Takes effect immediately no restart required.",
"settings.sync": "Sync settings",
"settings.sync.desc": "Keep appearance in sync with the server",
"settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync even when the switch is off.",
"settings.cache.header": "Preloading",
"settings.cache.title": "Preloading",