From 4125bfc7289280c0a09b532d3e5e513c85277255 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 27 May 2026 20:44:14 +0200 Subject: [PATCH] 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 --- Calendarr iOS.xcodeproj/project.pbxproj | 8 +- Calendarr iOS/Models/AppSettings.swift | 39 ++++ Calendarr iOS/Models/CalendarStore.swift | 57 +++++- Calendarr iOS/Models/Localization.swift | 10 + Calendarr iOS/Services/CalendarrAPI.swift | 27 +++ Calendarr iOS/Services/SettingsSync.swift | 152 ++++++++++++++++ Calendarr iOS/Views/AccountsView.swift | 39 +++- .../Views/Calendar/CalendarHostView.swift | 73 +++++++- Calendarr iOS/Views/Calendar/DayView.swift | 25 ++- .../Views/Calendar/EventDetailSheet.swift | 22 ++- Calendarr iOS/Views/Calendar/MonthView.swift | 18 +- .../Views/Calendar/TimeGridView.swift | 50 ++++- Calendarr iOS/Views/Calendar/WeekView.swift | 32 ++-- Calendarr iOS/Views/CalendarFilterSheet.swift | 25 ++- Calendarr iOS/Views/MenuSheet.swift | 24 +++ Calendarr iOS/Views/SettingsView.swift | 171 +++++++----------- 16 files changed, 616 insertions(+), 156 deletions(-) create mode 100644 Calendarr iOS/Services/SettingsSync.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 778f47f..85ec7a3 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -491,7 +491,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -509,7 +509,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -533,7 +533,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 86f0f37..d7a6962 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -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 } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 8a8a320..e567842 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -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 "-" for local / ical / google / homeassistant + // (e.g. "local-3", "google-5"). The filter UI keys off the numeric DB id, + // so strip any leading "-" 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) { + 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[.. = ["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 } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 515a06b..7f2b548 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -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", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index b123f9e..a1f53c3 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -365,4 +365,31 @@ class CalendarrAPI { ] _ = try await request("/api/homeassistant/events", method: "POST", body: body) } + + /// Delete a Home Assistant calendar event. + /// `calendarId` is the numeric HA-calendar DB id; `uid` is the HA event uid. + func deleteHAEvent(calendarId: Int, uid: String) async throws { + // uid is a path segment and may contain "/" or other reserved chars. + let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + let encUid = uid.addingPercentEncoding(withAllowedCharacters: allowed) ?? uid + _ = try await request("/api/homeassistant/events/\(calendarId)/\(encUid)", method: "DELETE") + } + + // MARK: – Calendar visibility (sidebar_hidden) + + /// Toggle a calendar's server-side visibility. Mirrors the web: hiding sets + /// `enabled=false, sidebar_hidden=true` (server then omits its events); + /// showing sets `enabled=true, sidebar_hidden=false`. Only CalDAV / Google / + /// Home Assistant have this flag; `local` / `ical` are a no-op. + func setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Bool) async throws { + let path: String + switch source { + case "caldav": path = "/api/caldav/calendars/\(calendarId)" + case "google": path = "/api/google/calendars/\(calendarId)" + case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)" + default: return + } + _ = try await request(path, method: "PUT", + body: ["enabled": !hidden, "sidebar_hidden": hidden]) + } } diff --git a/Calendarr iOS/Services/SettingsSync.swift b/Calendarr iOS/Services/SettingsSync.swift new file mode 100644 index 0000000..6bc1101 --- /dev/null +++ b/Calendarr iOS/Services/SettingsSync.swift @@ -0,0 +1,152 @@ +import Foundation + +extension Notification.Name { + /// Posted after a successful pull applied new settings to UserDefaults, so + /// views holding live state (CalendarHostView → store, widgets) can react. + static let settingsDidChange = Notification.Name("settingsDidChange") +} + +/// Two-way synchronisation of appearance/behaviour settings between the app and +/// the Calendarr server. The server is treated as the source of truth on pull; +/// local edits are pushed immediately so the server then holds the newest value. +/// +/// Two groups: +/// - **optional** (colors, contrasts, hour height) only sync when the user has +/// enabled the `settingsSync` toggle. +/// - **always** (default view, week start, dim past events) sync regardless of +/// the toggle, because they describe how the user expects the calendar to be +/// computed/presented everywhere. +enum SettingsSync { + + // MARK: – UserDefaults keys + + enum Key { + // optional group + static let primaryColor = "primaryColor" + static let accentColor = "accentColor" + static let todayColor = "todayColor" + static let textColor = "textColor" + static let backgroundColor = "backgroundColor" + static let lineColor = "lineColor" + static let monthDividerColor = "monthDividerColor" + static let monthLabelColor = "monthLabelColor" + static let textContrast = "textContrast" + static let lineContrast = "lineContrast" + static let hourHeight = "hourHeight" + // always group + static let defaultView = "defaultView" + static let weekStartDay = "weekStartDay" + static let dimPastEvents = "dimPastEvents" + // master switch + static let enabled = "settingsSync" + } + + static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } + + // MARK: – Defaults (mirror the historical hard-coded values) + + private static func int(_ key: String, _ fallback: Int) -> Int { + let v = UserDefaults.standard.object(forKey: key) as? Int + return v ?? fallback + } + private static func str(_ key: String, _ fallback: String) -> String { + UserDefaults.standard.string(forKey: key) ?? fallback + } + + // MARK: – Build AppSettings from local UserDefaults + + static func currentSettings() -> AppSettings { + var s = AppSettings() + s.primaryColor = str(Key.primaryColor, "#4285f4") + s.accentColor = str(Key.accentColor, "#ea4335") + s.todayColor = str(Key.todayColor, "#4285f4") + s.textColor = str(Key.textColor, "#FFFFFF") + s.backgroundColor = str(Key.backgroundColor, "#000000") + s.lineColor = str(Key.lineColor, "#3A3A3C") + s.monthDividerColor = str(Key.monthDividerColor, "#7090c0") + s.monthLabelColor = str(Key.monthLabelColor, "#7090c0") + s.textContrast = int(Key.textContrast, 3) + s.lineContrast = int(Key.lineContrast, 3) + s.hourHeight = int(Key.hourHeight, 60) + s.defaultView = str(Key.defaultView, "month") + s.weekStartDay = str(Key.weekStartDay, "monday") + s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents) + return s + } + + // MARK: – Apply a server snapshot to local UserDefaults + + /// Always writes the "always" trio. Writes the optional group only when + /// `includeOptional` is true. + static func apply(_ s: AppSettings, includeOptional: Bool) { + let d = UserDefaults.standard + // always group + d.set(s.defaultView, forKey: Key.defaultView) + d.set(s.weekStartDay, forKey: Key.weekStartDay) + d.set(s.dimPastEvents, forKey: Key.dimPastEvents) + guard includeOptional else { return } + // NOTE: textColor / backgroundColor / lineColor are intentionally NOT + // synced – the server has no columns for them (iOS-only). Writing the + // resilient-decoded defaults here would wipe the user's local choices. + d.set(s.primaryColor, forKey: Key.primaryColor) + d.set(s.accentColor, forKey: Key.accentColor) + d.set(s.todayColor, forKey: Key.todayColor) + d.set(s.monthDividerColor, forKey: Key.monthDividerColor) + d.set(s.monthLabelColor, forKey: Key.monthLabelColor) + d.set(s.textContrast, forKey: Key.textContrast) + d.set(s.lineContrast, forKey: Key.lineContrast) + d.set(s.hourHeight, forKey: Key.hourHeight) + } + + // MARK: – Pull + + /// Fetch the server's settings and apply them locally (server wins). + static func pull(api: CalendarrAPI) async { + guard let server = try? await api.getSettings() else { return } + apply(server, includeOptional: isEnabled) + await MainActor.run { + NotificationCenter.default.post(name: .settingsDidChange, object: nil) + } + } + + // MARK: – Push (debounced) + + private static var pushTask: Task? + + /// Schedule a debounced push. Repeated calls (e.g. while dragging a colour + /// slider) collapse into a single network write ~1.2 s after the last edit. + static func push(api: CalendarrAPI) { + pushTask?.cancel() + pushTask = Task { + try? await Task.sleep(for: .milliseconds(1200)) + if Task.isCancelled { return } + await performPush(api: api) + } + } + + /// Read-modify-write: start from the server's current settings so that, + /// when the optional group is NOT being synced, the server's colours stay + /// intact. Overwrite the trio always, the optional group only if enabled. + private static func performPush(api: CalendarrAPI) async { + guard var merged = try? await api.getSettings() else { return } + let local = currentSettings() + // always group + merged.defaultView = local.defaultView + merged.weekStartDay = local.weekStartDay + merged.dimPastEvents = local.dimPastEvents + if isEnabled { + merged.primaryColor = local.primaryColor + merged.accentColor = local.accentColor + merged.todayColor = local.todayColor + merged.textColor = local.textColor + merged.backgroundColor = local.backgroundColor + merged.lineColor = local.lineColor + merged.monthDividerColor = local.monthDividerColor + merged.monthLabelColor = local.monthLabelColor + merged.textContrast = local.textContrast + merged.lineContrast = local.lineContrast + merged.hourHeight = local.hourHeight + } + try? await api.updateSettings(merged) + } +} diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 2817923..ed9033e 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -195,9 +195,7 @@ struct AccountsView: View { .foregroundStyle(.secondary) Spacer() Button(L10n.t("accounts.banished_unhide", appLang)) { - banishedKeys.remove(key) - CalendarStore.saveBanishedKeys(banishedKeys) - NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) + unbanish(key) } .font(.callout) .foregroundStyle(Color.accentColor) @@ -208,6 +206,25 @@ struct AccountsView: View { } } + /// Re-show a banished calendar. For server-backed sources this clears the + /// server's sidebar_hidden (re-enabling the calendar); for local/ical it's + /// just the local set. + private func unbanish(_ key: String) { + banishedKeys.remove(key) + CalendarStore.saveBanishedKeys(banishedKeys) + NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) + if let parsed = CalendarStore.parseCalendarKey(key), + CalendarStore.serverManagedSources.contains(parsed.source) { + // The server excluded this calendar's events while hidden, so they + // aren't in the cache. Re-enable on the server, then force a refetch + // so the events actually reappear without a manual sync. + Task { + try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: false) + NotificationCenter.default.post(name: .manualSyncRequested, object: nil) + } + } + } + private func resolveBanished(_ key: String) -> (name: String, colorHex: String) { let parts = key.split(separator: ":", maxSplits: 1).map(String.init) guard parts.count == 2, let id = Int(parts[1]) else { @@ -282,6 +299,22 @@ struct AccountsView: View { async let g = (try? await api.getGoogleAccounts()) ?? [] async let h = (try? await api.getHomeAssistantAccounts()) ?? [] (caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h) + + // Reconcile banished list with the server's sidebar_hidden (server wins + // for CalDAV/Google/HA; local/ical keep their local state). + var b = banishedKeys + func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) { + let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)") + if hidden { b.insert(key) } else { b.remove(key) } + } + for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } } + for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } } + for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } } + if b != banishedKeys { + banishedKeys = b + CalendarStore.saveBanishedKeys(b) + NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) + } isLoading = false } diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index aaa9f5c..15ec78f 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -8,6 +8,10 @@ struct CalendarHostView: View { @AppStorage("cacheMonths") private var cacheMonths = 3 @AppStorage("appLanguage") private var appLang = "system" @AppStorage("backgroundColor") private var bgHex = "#000000" + @AppStorage("weekStartDay") private var weekStartDay = "monday" + @AppStorage("defaultView") private var defaultView = "month" + + @Environment(\.scenePhase) private var scenePhase @State private var store = CalendarStore() @State private var showEditor = false @@ -16,6 +20,7 @@ struct CalendarHostView: View { @State private var selectedEvent: CalEvent? = nil @State private var visibleMonth: Date = .now @State private var showFilter = false + @State private var didApplyDefaultView = false private var titleString: String { if store.viewType == .month { @@ -68,9 +73,16 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() } + .onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in + applyServerDrivenSettings(initial: false) + } + .onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in + Task { await forceReload() } + } } // MARK: – Liquid Glass variant @@ -123,9 +135,16 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() } + .onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in + applyServerDrivenSettings(initial: false) + } + .onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in + Task { await forceReload() } + } } // MARK: – Top bar (flat mode) @@ -322,12 +341,19 @@ struct CalendarHostView: View { CalendarSheets(store: store, showEditor: $showEditor, editorDate: $editorDate, editingEvent: $editingEvent, selectedEvent: $selectedEvent, showFilter: $showFilter, - api: api, reload: { await onNavigate() }) + api: api, + reload: { await onNavigate() }, + reloadForce: { await reloadVisible(force: true) }) } // MARK: – Loading logic private func startup() async { + // 0. Pull settings first so week-start / default-view are correct + // before we compute the initial range and load events. + await SettingsSync.pull(api: api) + applyServerDrivenSettings(initial: true) + await store.loadWritableCalendars(api: api) // 1. Load current view immediately (visible) let (s, e) = store.rangeForCurrentView() @@ -336,6 +362,25 @@ struct CalendarHostView: View { Task(priority: .background) { await store.prefetchBackground(api: api, months: cacheMonths) } + // 3. Periodic settings pull (tied to this .task's lifetime). + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(600)) + if Task.isCancelled { break } + await SettingsSync.pull(api: api) + } + } + + /// Apply the server-driven "always sync" settings to the live store. + /// `weekStartsOnMonday` is applied every time; the default view is applied + /// only once at startup so it never overrides the user's manual switches. + private func applyServerDrivenSettings(initial: Bool) { + store.weekStartsOnMonday = (weekStartDay != "sunday") + if initial, !didApplyDefaultView { + didApplyDefaultView = true + if let vt = CalViewType(rawValue: defaultView) { + store.viewType = vt + } + } } /// Called on every navigation – instant if within cache, fetches otherwise. @@ -344,12 +389,31 @@ struct CalendarHostView: View { await store.loadEvents(api: api, start: s, end: e) } + /// Re-fetch the visible range. With `force` it bypasses the cache so a just + /// created/edited event shows up immediately (the server is authoritative). + private func reloadVisible(force: Bool) async { + let (s, e) = store.rangeForCurrentView() + await store.loadEvents(api: api, start: s, end: e, force: force) + } + /// Called when cacheMonths setting changes – clear cache and re-prefetch. private func recache() async { store.invalidateCache() await startup() } + /// Manual sync from the menu: drop the event cache and re-fetch from the + /// server (the periodic loop in `startup()` is untouched, so we don't spawn + /// a second one). Settings were already pulled by the menu action. + private func forceReload() async { + store.invalidateCache() + let (s, e) = store.rangeForCurrentView() + await store.loadEvents(api: api, start: s, end: e) + Task(priority: .background) { + await store.prefetchBackground(api: api, months: cacheMonths) + } + } + /// Called when the user scrolls into a new month – refreshes the visible range /// immediately from cache, then fetches on demand if needed. private func ensureLoaded(around month: Date) async { @@ -380,19 +444,24 @@ private struct CalendarSheets: ViewModifier { @Binding var showFilter: Bool let api: CalendarrAPI let reload: () async -> Void + let reloadForce: () async -> Void func body(content: Content) -> some View { content .sheet(isPresented: $showEditor) { EventEditorSheet(api: api, store: store, initialDate: editorDate, editingEvent: editingEvent) { - editingEvent = nil; await reload() + // Create/edit changed server state → bust the cache so the + // new/updated event appears without a manual sync. + editingEvent = nil; await reloadForce() } } .sheet(item: $selectedEvent) { ev in EventDetailSheet(event: ev, api: api, store: store) { updated in selectedEvent = nil if let u = updated { editingEvent = u; showEditor = true } + // Delete already removed the event from the cache optimistically; + // a light cache refresh is enough here. await reload() } } diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift index 7e9c25e..d72664a 100644 --- a/Calendarr iOS/Views/Calendar/DayView.swift +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -5,14 +5,20 @@ struct DayView: View { let onEventTap: (CalEvent) -> Void let onCreateEvent: (Date) -> Void - @AppStorage("appLanguage") private var appLang = "system" - @AppStorage("todayColor") private var todayHex = "#4285f4" - @AppStorage("textColor") private var textHex = "#FFFFFF" - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("textContrast") private var textContrast = 3 + @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout private var cal: Calendar { store.userCalendar } - private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) } - private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } } + private var allDayEvents: [CalEvent] { + store.events(on: store.currentDate).filter { $0.isAllDay || eventSpansMultipleDays($0) } + } + private var timedEvents: [CalEvent] { + store.events(on: store.currentDate).filter { !$0.isAllDay && !eventSpansMultipleDays($0) } + } var body: some View { VStack(spacing: 0) { @@ -97,7 +103,7 @@ struct DayView: View { Color.clear.frame(height: hourHeight) Text(String(format: "%02d:00", h)) .font(.system(size: 10)) - .foregroundStyle(Color(hex: textHex).opacity(0.6)) + .foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .offset(y: -6) } } @@ -131,7 +137,8 @@ private struct DayHourSlot: View { let language: String let onCreateEvent: (Date) -> Void - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineContrast") private var lineContrast = 3 private var date: Date { Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day @@ -139,7 +146,7 @@ private struct DayHourSlot: View { var body: some View { VStack(spacing: 0) { - Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5) Color.clear.frame(height: hourHeight - 0.5) } .contentShape(Rectangle()) diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index b057b1f..f73cef7 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -40,6 +40,12 @@ struct EventDetailSheet: View { event.source == "local" || event.source == "caldav" } + /// Home Assistant events can't be edited in-app (no editor support), but + /// the server does support deleting them. + private var canDelete: Bool { + canEdit || event.source == "homeassistant" + } + var body: some View { NavigationStack { List { @@ -86,7 +92,7 @@ struct EventDetailSheet: View { } } - if canEdit { + if canDelete { Section { Button(role: .destructive) { showDeleteConfirm = true @@ -115,7 +121,7 @@ struct EventDetailSheet: View { } } } - .confirmationDialog("Termin löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + .alert("Termin löschen?", isPresented: $showDeleteConfirm) { Button("Löschen", role: .destructive) { Task { await deleteEvent() } } @@ -129,12 +135,20 @@ struct EventDetailSheet: View { private func deleteEvent() async { isDeleting = true do { - if event.source == "local" { + switch event.source { + case "local": try await api.deleteLocalEvent(uid: event.id) - } else { + case "homeassistant": + // calendarId looks like "homeassistant-42" → numeric DB id 42 + let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0 + try await api.deleteHAEvent(calendarId: calId, uid: event.id) + default: let calId = Int(event.calendarId) try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId) } + // Optimistically drop it from the cache so it vanishes immediately, + // regardless of how long the source takes to propagate the delete. + store.removeCachedEvent(id: event.id) await onDone(nil) } catch { isDeleting = false diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index c4af936..aeb24b2 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -26,6 +26,7 @@ struct MonthView: View { @AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("textContrast") private var textContrast = 3 @State private var scrolledWeek: Date? = nil @State private var didInitialScroll = false @@ -98,7 +99,7 @@ struct MonthView: View { ForEach(weekdayHeaders, id: \.self) { d in Text(d) .font(.caption2.weight(.semibold)) - .foregroundStyle(Color(hex: textHex).opacity(0.7)) + .foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight) } } @@ -291,6 +292,9 @@ private struct DayCell: View { let onShowWeek: () -> Void let onShowDay: () -> Void + @AppStorage("textContrast") private var textContrast = 3 + @AppStorage("lineContrast") private var lineContrast = 3 + private var cal: Calendar { Calendar.current } private var dayNum: Int { cal.component(.day, from: date) } private var isFirstOfMonth: Bool { dayNum == 1 } @@ -330,14 +334,14 @@ private struct DayCell: View { if extraCount > 0 { Text("+\(extraCount)") .font(.system(size: 9, weight: .medium)) - .foregroundStyle(textColor.opacity(0.6)) + .foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast))) .padding(.leading, 4) } Spacer(minLength: 0) if let wn = weekNumber { Text("\(cwLabel) \(wn)") .font(.system(size: 9, weight: .medium)) - .foregroundStyle(textColor.opacity(0.6)) + .foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast))) .padding(.trailing, 4) } } @@ -345,11 +349,11 @@ private struct DayCell: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .trailing) { - Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5) + Rectangle().fill(lineColor.opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) } .overlay(alignment: .top) { Rectangle() - .fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3)) + .fill(edge == .topHighlight ? dividerColor : lineColor.opacity(gridLineOpacity(lineContrast))) .frame(height: edge == .topHighlight ? 1.5 : 0.5) } .overlay(alignment: .bottom) { @@ -377,6 +381,9 @@ private struct DayCell: View { private struct EventBar: View { let event: CalEvent + @AppStorage("dimPastEvents") private var dimPast = false + + private var isPast: Bool { event.endDate < .now } var body: some View { HStack(spacing: 3) { @@ -390,5 +397,6 @@ private struct EventBar: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color(hex: event.effectiveColor)) .clipShape(RoundedRectangle(cornerRadius: 3)) + .opacity(dimPast && isPast ? 0.5 : 1.0) } } diff --git a/Calendarr iOS/Views/Calendar/TimeGridView.swift b/Calendarr iOS/Views/Calendar/TimeGridView.swift index f183fba..7cbafc3 100644 --- a/Calendarr iOS/Views/Calendar/TimeGridView.swift +++ b/Calendarr iOS/Views/Calendar/TimeGridView.swift @@ -1,10 +1,54 @@ import SwiftUI // Shared constants used by WeekView, DayView, EventEditorSheet -let hourHeight: CGFloat = 60 let timeColumnWidth: CGFloat = 44 let hours = Array(0..<24) +/// Live hour-row height, driven by the synced `hourHeight` setting. +/// Falls back to 60 when unset (fresh install / value 0). Views that lay out +/// against this also observe `@AppStorage("hourHeight")` so their body +/// re-renders when it changes. +var hourHeight: CGFloat { + let v = UserDefaults.standard.integer(forKey: "hourHeight") + return v > 0 ? CGFloat(v) : 60 +} + +/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"), +/// mapped from the 1–4 `textContrast` level. Level 3 ≈ the previous hard-coded +/// look so existing installs are visually unchanged. +func secondaryTextOpacity(_ level: Int) -> Double { + switch level { + case 1: return 0.4 + case 2: return 0.55 + case 4: return 1.0 + default: return 0.75 + } +} + +/// Opacity for grid lines / separators, mapped from the 1–4 `lineContrast` +/// level. Level 3 ≈ the previous hard-coded ~0.4 look. +func gridLineOpacity(_ level: Int) -> Double { + switch level { + case 1: return 0.15 + case 2: return 0.3 + case 4: return 0.8 + default: return 0.5 + } +} + +/// A timed (non-all-day) event that crosses a day boundary. Such events must +/// NOT be placed in the hourly grid — their height would be `duration × +/// hourHeight`, i.e. taller than the whole day, rendering as a giant block +/// (and, sharing one id across days, only drawing on the first day). They are +/// shown in the all-day strip instead, like all-day events. +func eventSpansMultipleDays(_ ev: CalEvent) -> Bool { + guard !ev.isAllDay, ev.endDate > ev.startDate else { return false } + let cal = Calendar.current + // End is exclusive: an event ending exactly at midnight is still single-day. + let lastInstant = ev.endDate.addingTimeInterval(-1) + return !cal.isDate(ev.startDate, inSameDayAs: lastInstant) +} + // Position helpers func eventTop(_ ev: CalEvent) -> CGFloat { let cal = Calendar.current @@ -21,6 +65,9 @@ func eventHeight(_ ev: CalEvent) -> CGFloat { // Shared event block used in WeekView and DayView struct EventBlock: View { let event: CalEvent + @AppStorage("dimPastEvents") private var dimPast = false + + private var isPast: Bool { event.endDate < .now } var body: some View { RoundedRectangle(cornerRadius: 4) @@ -41,5 +88,6 @@ struct EventBlock: View { .padding(4) } .padding(.horizontal, 1) + .opacity(dimPast && isPast ? 0.5 : 1.0) } } diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index fb0d0d1..55128f9 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -7,10 +7,13 @@ struct WeekView: View { let onShowMonth: (Date) -> Void let onShowDay: (Date) -> Void - @AppStorage("appLanguage") private var appLang = "system" - @AppStorage("todayColor") private var todayHex = "#4285f4" - @AppStorage("textColor") private var textHex = "#FFFFFF" - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("textContrast") private var textContrast = 3 + @AppStorage("lineContrast") private var lineContrast = 3 + @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout private var cal: Calendar { store.userCalendar } @@ -21,14 +24,16 @@ struct WeekView: View { private var timedEvents: [(Int, CalEvent)] { weekDays.enumerated().flatMap { idx, day in - store.events(on: day).filter { !$0.isAllDay }.map { (idx, $0) } + store.events(on: day) + .filter { !$0.isAllDay && !eventSpansMultipleDays($0) } + .map { (idx, $0) } } } private var allDayEvents: [CalEvent] { let s = weekDays.first ?? .now let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)! - return store.events(in: s, end: e).filter(\.isAllDay) + return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) } } private var todayIndex: Int? { @@ -56,10 +61,10 @@ struct WeekView: View { ForEach(weekDays, id: \.self) { day in Text(headerFmt.string(from: day).uppercased()) .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7)) + .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .frame(maxWidth: .infinity, minHeight: 36) .overlay(alignment: .trailing) { - Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) } } } @@ -94,7 +99,7 @@ struct WeekView: View { .padding(.horizontal, 1) .frame(maxWidth: .infinity) .overlay(alignment: .trailing) { - Rectangle().fill(Color(.separator)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) } } } @@ -127,7 +132,7 @@ struct WeekView: View { } .frame(width: colW) .overlay(alignment: .trailing) { - Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) } } } @@ -171,7 +176,7 @@ struct WeekView: View { Color.clear.frame(height: hourHeight) Text(String(format: "%02d:00", h)) .font(.system(size: 10)) - .foregroundStyle(Color(hex: textHex).opacity(0.6)) + .foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .offset(y: -6) } } @@ -208,7 +213,8 @@ struct HourSlot: View { let onShowMonth: (Date) -> Void let onShowDay: (Date) -> Void - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineContrast") private var lineContrast = 3 private var date: Date { Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day @@ -216,7 +222,7 @@ struct HourSlot: View { var body: some View { VStack(spacing: 0) { - Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5) Color.clear.frame(height: hourHeight - 0.5) } .contentShape(Rectangle()) diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index e932c1b..57e9fc1 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -136,7 +136,9 @@ struct CalendarFilterSheet: View { let isVisible = !hidden.contains(key) Button { if isVisible { hidden.insert(key) } else { hidden.remove(key) } - store.setCalendarHidden(key, hidden: !isVisible) + // New hidden state == was-visible (flip). Previous code passed the + // inverse, which persisted the opposite of what the UI showed. + store.setCalendarHidden(key, hidden: isVisible) } label: { HStack(spacing: 12) { Circle() @@ -158,6 +160,7 @@ struct CalendarFilterSheet: View { hidden.remove(key) banished.insert(key) store.setCalendarBanished(key, banished: true) + pushBanishToServer(key: key, hidden: true) } label: { Label(L10n.t("filter.banish", appLang), systemImage: "archivebox") } @@ -175,6 +178,19 @@ struct CalendarFilterSheet: View { async let h = (try? await api.getHomeAssistantAccounts()) ?? [] (caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h) + // Reconcile banished state with the server's sidebar_hidden flags + // (server wins for CalDAV/Google/HA; local/ical keep their local state). + var b = store.banishedCalendarKeys + func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) { + let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)") + if hidden { b.insert(key) } else { b.remove(key) } + } + for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } } + for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } } + for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } } + store.setBanishedCalendars(b) + banished = b + var keys = Set() for cal in localCalendars { keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) @@ -200,4 +216,11 @@ struct CalendarFilterSheet: View { allKeys = keys isLoading = false } + + /// For server-backed sources, persist the banish on the server too. + private func pushBanishToServer(key: String, hidden: Bool) { + guard let parsed = CalendarStore.parseCalendarKey(key), + CalendarStore.serverManagedSources.contains(parsed.source) else { return } + Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) } + } } diff --git a/Calendarr iOS/Views/MenuSheet.swift b/Calendarr iOS/Views/MenuSheet.swift index 1bb6a7f..032bd5f 100644 --- a/Calendarr iOS/Views/MenuSheet.swift +++ b/Calendarr iOS/Views/MenuSheet.swift @@ -5,6 +5,7 @@ struct MenuSheet: View { @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss @AppStorage("appLanguage") private var appLang = "system" + @State private var isSyncing = false var body: some View { NavigationStack { @@ -68,6 +69,19 @@ struct MenuSheet: View { } } + Section(L10n.t("menu.sync.section", appLang)) { + Button { + Task { await syncNow() } + } label: { + HStack { + Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath") + Spacer() + if isSyncing { ProgressView() } + } + } + .disabled(isSyncing) + } + Section { Button(role: .destructive) { dismiss() @@ -88,4 +102,14 @@ struct MenuSheet: View { } } } + + /// Manual sync: pull appearance/behaviour settings from the server, then + /// ask the calendar host to re-fetch events (cache-busting). + private func syncNow() async { + isSyncing = true + await SettingsSync.pull(api: api) + NotificationCenter.default.post(name: .manualSyncRequested, object: nil) + isSyncing = false + dismiss() + } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index f5eb66c..01edd50 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -2,12 +2,8 @@ import SwiftUI struct SettingsView: View { let api: CalendarrAPI - @State private var settings = AppSettings() - @State private var isLoading = true - @State private var isSaving = false - @State private var toast = "" - @State private var showToast = false @AppStorage("liquidGlass") private var liquidGlass = false + @AppStorage("settingsSync") private var settingsSync = false @AppStorage("cacheMonths") private var cacheMonths = 3 @AppStorage("appLanguage") private var appLang = "system" @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @@ -18,65 +14,52 @@ struct SettingsView: View { @AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("primaryColor") private var primaryHex = "#4285f4" @AppStorage("accentColor") private var accentHex = "#ea4335" + // Previously server-only; now AppStorage-backed so they persist and the + // calendar views actually apply them. + @AppStorage("textContrast") private var textContrast = 3 + @AppStorage("lineContrast") private var lineContrast = 3 + @AppStorage("hourHeight") private var hourHeight = 60 + @AppStorage("defaultView") private var defaultView = "month" + @AppStorage("weekStartDay") private var weekStartDay = "monday" + @AppStorage("dimPastEvents") private var dimPastEvents = false var body: some View { NavigationStack { - Group { - if isLoading { - ProgressView(L10n.t("settings.loading", appLang)) - } else { - Form { - liquidGlassSection - cacheSection - spracheSection - farbenSection - schriftSection - linienSection - ansichtSection - stundenSection - } - } + Form { + liquidGlassSection + cacheSection + spracheSection + farbenSection + schriftSection + linienSection + ansichtSection + stundenSection } .navigationTitle(L10n.t("settings.title", appLang)) .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - Task { await save() } - } label: { - if isSaving { - ProgressView() - } else { - Text(L10n.t("settings.save", appLang)).bold() - } - } - .disabled(isSaving) - } - } - .overlay(alignment: .bottom) { - if showToast { - Text(toast) - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(.regularMaterial) - .clipShape(Capsule()) - .padding(.bottom, 20) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .animation(.easeInOut, value: showToast) } - .task { await load() } - // Live-update widgets the moment any appearance value changes, so the - // user sees the new colours without having to wait for the next event - // sync or save the settings. - .onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly() } + // Reflect the latest server values when opening the screen. + .task { await SettingsSync.pull(api: api) } + // Appearance changes update widgets live; synced values are also pushed + // to the server (debounced). `push` itself decides what actually gets + // sent based on the sync toggle, so every change can simply call it. + .onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: dividerHex) { _, _ in SettingsSync.push(api: api) } + .onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) } + .onChange(of: textContrast) { _, _ in SettingsSync.push(api: api) } + .onChange(of: lineContrast) { _, _ in SettingsSync.push(api: api) } + .onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) } + .onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) } + .onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) } + .onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) } .onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() } + // Enabling sync adopts the server's appearance (server wins). + .onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } } } // MARK: – Liquid Glass @@ -97,10 +80,25 @@ struct SettingsView: View { } } .tint(Color.accentColor) + + Toggle(isOn: $settingsSync) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.t("settings.sync", appLang)) + Text(L10n.t("settings.sync.desc", appLang)) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(.teal) + } + } + .tint(Color.accentColor) } header: { Text(L10n.t("settings.appdesign", appLang)) } footer: { - Text(L10n.t("settings.liquidglass.footer", appLang)) + Text(L10n.t("settings.sync.footer", appLang)) .font(.caption) } } @@ -177,7 +175,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) ContrastSelector( - value: $settings.textContrast, + value: $textContrast, options: [ (1, L10n.t("settings.contrast.dark", appLang)), (2, L10n.t("settings.contrast.medium", appLang)), @@ -201,7 +199,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) ContrastSelector( - value: $settings.lineContrast, + value: $lineContrast, options: [ (1, L10n.t("settings.linecontrast.barely", appLang)), (2, L10n.t("settings.linecontrast.subtle", appLang)), @@ -218,18 +216,18 @@ struct SettingsView: View { var ansichtSection: some View { Section(L10n.t("settings.calview", appLang)) { - Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) { + Picker(L10n.t("settings.defaultview", appLang), selection: $defaultView) { Text(L10n.t("view.month", appLang)).tag("month") Text(L10n.t("view.week", appLang)).tag("week") Text(L10n.t("view.day", appLang)).tag("day") Text(L10n.t("view.quarter", appLang)).tag("quarter") Text(L10n.t("view.agenda", appLang)).tag("agenda") } - Picker(L10n.t("settings.firstweekday", appLang), selection: $settings.weekStartDay) { + Picker(L10n.t("settings.firstweekday", appLang), selection: $weekStartDay) { Text(L10n.t("settings.monday", appLang)).tag("monday") Text(L10n.t("settings.sunday", appLang)).tag("sunday") } - Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents) + Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents) .tint(Color.accentColor) } } @@ -245,7 +243,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) ContrastSelector( - value: $settings.hourHeight, + value: $hourHeight, options: [ (28, L10n.t("settings.hourheight.compact", appLang)), (44, L10n.t("settings.hourheight.normal", appLang)), @@ -258,53 +256,6 @@ struct SettingsView: View { } } - // MARK: – Actions - - private func load() async { - isLoading = true - defer { isLoading = false } - if let s = try? await api.getSettings() { - settings = s - // Mirror server-side color settings so calendar views (which read AppStorage) see them. - dividerHex = s.monthDividerColor - labelHex = s.monthLabelColor - todayHex = s.todayColor - textHex = s.textColor - bgHex = s.backgroundColor - lineHex = s.lineColor - primaryHex = s.primaryColor - accentHex = s.accentColor - } - } - - private func save() async { - isSaving = true - defer { isSaving = false } - // Push local AppStorage colors back into the settings struct before saving. - settings.monthDividerColor = dividerHex - settings.monthLabelColor = labelHex - settings.todayColor = todayHex - settings.textColor = textHex - settings.backgroundColor = bgHex - settings.lineColor = lineHex - settings.primaryColor = primaryHex - settings.accentColor = accentHex - do { - try await api.updateSettings(settings) - showNotice(L10n.t("settings.saved", appLang)) - } catch { - showNotice(error.localizedDescription) - } - } - - private func showNotice(_ msg: String) { - toast = msg - withAnimation { showToast = true } - Task { - try? await Task.sleep(for: .seconds(2)) - withAnimation { showToast = false } - } - } } // MARK: – Reusable Components