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