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:
@@ -491,7 +491,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -533,7 +533,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -551,7 +551,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -34,6 +34,33 @@ struct AppSettings: Codable {
|
|||||||
case backgroundColor = "background_color"
|
case backgroundColor = "background_color"
|
||||||
case lineColor = "line_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 {
|
struct CalDAVAccount: Codable, Identifiable {
|
||||||
@@ -124,10 +151,22 @@ struct HACalendar: Codable, Identifiable {
|
|||||||
var entityId: String
|
var entityId: String
|
||||||
var color: String?
|
var color: String?
|
||||||
var enabled: Bool
|
var enabled: Bool
|
||||||
|
var sidebarHidden: Bool
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, color, enabled
|
case id, name, color, enabled
|
||||||
case entityId = "entity_id"
|
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
|
/// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
|
||||||
/// listens for this in `CalendarHostView` and refreshes its filter.
|
/// listens for this in `CalendarHostView` and refreshes its filter.
|
||||||
static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
|
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 {
|
enum CalViewType: String, CaseIterable {
|
||||||
@@ -107,7 +112,16 @@ class CalendarStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func calendarKey(source: String, calendarId: String) -> String {
|
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
|
// MARK: – Banished-calendar persistence
|
||||||
@@ -149,6 +163,18 @@ class CalendarStore {
|
|||||||
publishWidgetSnapshot()
|
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
|
/// Re-read the banished set from UserDefaults – called when an external
|
||||||
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
|
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
|
||||||
func syncBanishedFromDefaults() {
|
func syncBanishedFromDefaults() {
|
||||||
@@ -158,6 +184,17 @@ class CalendarStore {
|
|||||||
publishWidgetSnapshot()
|
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 userCalendar: Calendar {
|
||||||
var cal = Calendar.current
|
var cal = Calendar.current
|
||||||
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
|
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
|
// MARK: – Network loading
|
||||||
|
|
||||||
/// Load events for a specific range – skips network if already cached.
|
/// Load events for a specific range. Skips the network if already cached,
|
||||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date) async {
|
/// unless `force` is set (used after create/edit to pull fresh server data
|
||||||
if isCached(start: start, end: end) {
|
/// 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)
|
refreshFromCache(start: start, end: end)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"menu.server": "Server",
|
"menu.server": "Server",
|
||||||
"menu.logout": "Abmelden",
|
"menu.logout": "Abmelden",
|
||||||
"menu.admin": "Admin",
|
"menu.admin": "Admin",
|
||||||
|
"menu.sync": "Mit Server synchronisieren",
|
||||||
|
"menu.sync.section": "Synchronisierung",
|
||||||
|
|
||||||
// Settings – chrome
|
// Settings – chrome
|
||||||
"settings.title": "Darstellung",
|
"settings.title": "Darstellung",
|
||||||
@@ -76,6 +78,9 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.liquidglass": "Liquid Glass",
|
"settings.liquidglass": "Liquid Glass",
|
||||||
"settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste",
|
"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.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.header": "Vorladen",
|
||||||
"settings.cache.title": "Vorladen",
|
"settings.cache.title": "Vorladen",
|
||||||
@@ -320,6 +325,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"menu.server": "Server",
|
"menu.server": "Server",
|
||||||
"menu.logout": "Sign out",
|
"menu.logout": "Sign out",
|
||||||
"menu.admin": "Admin",
|
"menu.admin": "Admin",
|
||||||
|
"menu.sync": "Sync with server",
|
||||||
|
"menu.sync.section": "Synchronization",
|
||||||
|
|
||||||
"settings.title": "Appearance",
|
"settings.title": "Appearance",
|
||||||
"settings.loading": "Loading settings…",
|
"settings.loading": "Loading settings…",
|
||||||
@@ -330,6 +337,9 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.liquidglass": "Liquid Glass",
|
"settings.liquidglass": "Liquid Glass",
|
||||||
"settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar",
|
"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.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.header": "Preloading",
|
||||||
"settings.cache.title": "Preloading",
|
"settings.cache.title": "Preloading",
|
||||||
|
|||||||
@@ -365,4 +365,31 @@ class CalendarrAPI {
|
|||||||
]
|
]
|
||||||
_ = try await request("/api/homeassistant/events", method: "POST", body: body)
|
_ = 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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
152
Calendarr iOS/Services/SettingsSync.swift
Normal file
152
Calendarr iOS/Services/SettingsSync.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,9 +195,7 @@ struct AccountsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(L10n.t("accounts.banished_unhide", appLang)) {
|
Button(L10n.t("accounts.banished_unhide", appLang)) {
|
||||||
banishedKeys.remove(key)
|
unbanish(key)
|
||||||
CalendarStore.saveBanishedKeys(banishedKeys)
|
|
||||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
|
||||||
}
|
}
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(Color.accentColor)
|
.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) {
|
private func resolveBanished(_ key: String) -> (name: String, colorHex: String) {
|
||||||
let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
|
let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
|
||||||
guard parts.count == 2, let id = Int(parts[1]) else {
|
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 g = (try? await api.getGoogleAccounts()) ?? []
|
||||||
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||||
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
|
(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
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ struct CalendarHostView: View {
|
|||||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
@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 store = CalendarStore()
|
||||||
@State private var showEditor = false
|
@State private var showEditor = false
|
||||||
@@ -16,6 +20,7 @@ struct CalendarHostView: View {
|
|||||||
@State private var selectedEvent: CalEvent? = nil
|
@State private var selectedEvent: CalEvent? = nil
|
||||||
@State private var visibleMonth: Date = .now
|
@State private var visibleMonth: Date = .now
|
||||||
@State private var showFilter = false
|
@State private var showFilter = false
|
||||||
|
@State private var didApplyDefaultView = false
|
||||||
|
|
||||||
private var titleString: String {
|
private var titleString: String {
|
||||||
if store.viewType == .month {
|
if store.viewType == .month {
|
||||||
@@ -68,9 +73,16 @@ struct CalendarHostView: View {
|
|||||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||||
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
.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
|
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||||
store.syncBanishedFromDefaults()
|
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
|
// MARK: – Liquid Glass variant
|
||||||
@@ -123,9 +135,16 @@ struct CalendarHostView: View {
|
|||||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||||
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
.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
|
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||||
store.syncBanishedFromDefaults()
|
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)
|
// MARK: – Top bar (flat mode)
|
||||||
@@ -322,12 +341,19 @@ struct CalendarHostView: View {
|
|||||||
CalendarSheets(store: store, showEditor: $showEditor,
|
CalendarSheets(store: store, showEditor: $showEditor,
|
||||||
editorDate: $editorDate, editingEvent: $editingEvent,
|
editorDate: $editorDate, editingEvent: $editingEvent,
|
||||||
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
||||||
api: api, reload: { await onNavigate() })
|
api: api,
|
||||||
|
reload: { await onNavigate() },
|
||||||
|
reloadForce: { await reloadVisible(force: true) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Loading logic
|
// MARK: – Loading logic
|
||||||
|
|
||||||
private func startup() async {
|
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)
|
await store.loadWritableCalendars(api: api)
|
||||||
// 1. Load current view immediately (visible)
|
// 1. Load current view immediately (visible)
|
||||||
let (s, e) = store.rangeForCurrentView()
|
let (s, e) = store.rangeForCurrentView()
|
||||||
@@ -336,6 +362,25 @@ struct CalendarHostView: View {
|
|||||||
Task(priority: .background) {
|
Task(priority: .background) {
|
||||||
await store.prefetchBackground(api: api, months: cacheMonths)
|
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.
|
/// 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)
|
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.
|
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
|
||||||
private func recache() async {
|
private func recache() async {
|
||||||
store.invalidateCache()
|
store.invalidateCache()
|
||||||
await startup()
|
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
|
/// Called when the user scrolls into a new month – refreshes the visible range
|
||||||
/// immediately from cache, then fetches on demand if needed.
|
/// immediately from cache, then fetches on demand if needed.
|
||||||
private func ensureLoaded(around month: Date) async {
|
private func ensureLoaded(around month: Date) async {
|
||||||
@@ -380,19 +444,24 @@ private struct CalendarSheets: ViewModifier {
|
|||||||
@Binding var showFilter: Bool
|
@Binding var showFilter: Bool
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let reload: () async -> Void
|
let reload: () async -> Void
|
||||||
|
let reloadForce: () async -> Void
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.sheet(isPresented: $showEditor) {
|
.sheet(isPresented: $showEditor) {
|
||||||
EventEditorSheet(api: api, store: store,
|
EventEditorSheet(api: api, store: store,
|
||||||
initialDate: editorDate, editingEvent: editingEvent) {
|
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
|
.sheet(item: $selectedEvent) { ev in
|
||||||
EventDetailSheet(event: ev, api: api, store: store) { updated in
|
EventDetailSheet(event: ev, api: api, store: store) { updated in
|
||||||
selectedEvent = nil
|
selectedEvent = nil
|
||||||
if let u = updated { editingEvent = u; showEditor = true }
|
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()
|
await reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ struct DayView: View {
|
|||||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@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 cal: Calendar { store.userCalendar }
|
||||||
private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) }
|
private var allDayEvents: [CalEvent] {
|
||||||
private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } }
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -97,7 +103,7 @@ struct DayView: View {
|
|||||||
Color.clear.frame(height: hourHeight)
|
Color.clear.frame(height: hourHeight)
|
||||||
Text(String(format: "%02d:00", h))
|
Text(String(format: "%02d:00", h))
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(Color(hex: textHex).opacity(0.6))
|
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||||
.offset(y: -6)
|
.offset(y: -6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +138,7 @@ private struct DayHourSlot: View {
|
|||||||
let onCreateEvent: (Date) -> Void
|
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 {
|
private var date: Date {
|
||||||
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||||
@@ -139,7 +146,7 @@ private struct DayHourSlot: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
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)
|
Color.clear.frame(height: hourHeight - 0.5)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ struct EventDetailSheet: View {
|
|||||||
event.source == "local" || event.source == "caldav"
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
@@ -86,7 +92,7 @@ struct EventDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if canEdit {
|
if canDelete {
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showDeleteConfirm = true
|
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) {
|
Button("Löschen", role: .destructive) {
|
||||||
Task { await deleteEvent() }
|
Task { await deleteEvent() }
|
||||||
}
|
}
|
||||||
@@ -129,12 +135,20 @@ struct EventDetailSheet: View {
|
|||||||
private func deleteEvent() async {
|
private func deleteEvent() async {
|
||||||
isDeleting = true
|
isDeleting = true
|
||||||
do {
|
do {
|
||||||
if event.source == "local" {
|
switch event.source {
|
||||||
|
case "local":
|
||||||
try await api.deleteLocalEvent(uid: event.id)
|
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)
|
let calId = Int(event.calendarId)
|
||||||
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
|
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)
|
await onDone(nil)
|
||||||
} catch {
|
} catch {
|
||||||
isDeleting = false
|
isDeleting = false
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ struct MonthView: View {
|
|||||||
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
|
||||||
@State private var scrolledWeek: Date? = nil
|
@State private var scrolledWeek: Date? = nil
|
||||||
@State private var didInitialScroll = false
|
@State private var didInitialScroll = false
|
||||||
@@ -98,7 +99,7 @@ struct MonthView: View {
|
|||||||
ForEach(weekdayHeaders, id: \.self) { d in
|
ForEach(weekdayHeaders, id: \.self) { d in
|
||||||
Text(d)
|
Text(d)
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(Color(hex: textHex).opacity(0.7))
|
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||||
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
|
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,6 +292,9 @@ private struct DayCell: View {
|
|||||||
let onShowWeek: () -> Void
|
let onShowWeek: () -> Void
|
||||||
let onShowDay: () -> Void
|
let onShowDay: () -> Void
|
||||||
|
|
||||||
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
|
|
||||||
private var cal: Calendar { Calendar.current }
|
private var cal: Calendar { Calendar.current }
|
||||||
private var dayNum: Int { cal.component(.day, from: date) }
|
private var dayNum: Int { cal.component(.day, from: date) }
|
||||||
private var isFirstOfMonth: Bool { dayNum == 1 }
|
private var isFirstOfMonth: Bool { dayNum == 1 }
|
||||||
@@ -330,14 +334,14 @@ private struct DayCell: View {
|
|||||||
if extraCount > 0 {
|
if extraCount > 0 {
|
||||||
Text("+\(extraCount)")
|
Text("+\(extraCount)")
|
||||||
.font(.system(size: 9, weight: .medium))
|
.font(.system(size: 9, weight: .medium))
|
||||||
.foregroundStyle(textColor.opacity(0.6))
|
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if let wn = weekNumber {
|
if let wn = weekNumber {
|
||||||
Text("\(cwLabel) \(wn)")
|
Text("\(cwLabel) \(wn)")
|
||||||
.font(.system(size: 9, weight: .medium))
|
.font(.system(size: 9, weight: .medium))
|
||||||
.foregroundStyle(textColor.opacity(0.6))
|
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,11 +349,11 @@ private struct DayCell: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.overlay(alignment: .trailing) {
|
.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) {
|
.overlay(alignment: .top) {
|
||||||
Rectangle()
|
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)
|
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
@@ -377,6 +381,9 @@ private struct DayCell: View {
|
|||||||
|
|
||||||
private struct EventBar: View {
|
private struct EventBar: View {
|
||||||
let event: CalEvent
|
let event: CalEvent
|
||||||
|
@AppStorage("dimPastEvents") private var dimPast = false
|
||||||
|
|
||||||
|
private var isPast: Bool { event.endDate < .now }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
@@ -390,5 +397,6 @@ private struct EventBar: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color(hex: event.effectiveColor))
|
.background(Color(hex: event.effectiveColor))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
|
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,54 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// Shared constants used by WeekView, DayView, EventEditorSheet
|
// Shared constants used by WeekView, DayView, EventEditorSheet
|
||||||
let hourHeight: CGFloat = 60
|
|
||||||
let timeColumnWidth: CGFloat = 44
|
let timeColumnWidth: CGFloat = 44
|
||||||
let hours = Array(0..<24)
|
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
|
// Position helpers
|
||||||
func eventTop(_ ev: CalEvent) -> CGFloat {
|
func eventTop(_ ev: CalEvent) -> CGFloat {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
@@ -21,6 +65,9 @@ func eventHeight(_ ev: CalEvent) -> CGFloat {
|
|||||||
// Shared event block used in WeekView and DayView
|
// Shared event block used in WeekView and DayView
|
||||||
struct EventBlock: View {
|
struct EventBlock: View {
|
||||||
let event: CalEvent
|
let event: CalEvent
|
||||||
|
@AppStorage("dimPastEvents") private var dimPast = false
|
||||||
|
|
||||||
|
private var isPast: Bool { event.endDate < .now }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
@@ -41,5 +88,6 @@ struct EventBlock: View {
|
|||||||
.padding(4)
|
.padding(4)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 1)
|
.padding(.horizontal, 1)
|
||||||
|
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ struct WeekView: View {
|
|||||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@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 }
|
private var cal: Calendar { store.userCalendar }
|
||||||
|
|
||||||
@@ -21,14 +24,16 @@ struct WeekView: View {
|
|||||||
|
|
||||||
private var timedEvents: [(Int, CalEvent)] {
|
private var timedEvents: [(Int, CalEvent)] {
|
||||||
weekDays.enumerated().flatMap { idx, day in
|
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] {
|
private var allDayEvents: [CalEvent] {
|
||||||
let s = weekDays.first ?? .now
|
let s = weekDays.first ?? .now
|
||||||
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .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? {
|
private var todayIndex: Int? {
|
||||||
@@ -56,10 +61,10 @@ struct WeekView: View {
|
|||||||
ForEach(weekDays, id: \.self) { day in
|
ForEach(weekDays, id: \.self) { day in
|
||||||
Text(headerFmt.string(from: day).uppercased())
|
Text(headerFmt.string(from: day).uppercased())
|
||||||
.font(.system(size: 10, weight: .semibold))
|
.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)
|
.frame(maxWidth: .infinity, minHeight: 36)
|
||||||
.overlay(alignment: .trailing) {
|
.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)
|
.padding(.horizontal, 1)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.overlay(alignment: .trailing) {
|
.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)
|
.frame(width: colW)
|
||||||
.overlay(alignment: .trailing) {
|
.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)
|
Color.clear.frame(height: hourHeight)
|
||||||
Text(String(format: "%02d:00", h))
|
Text(String(format: "%02d:00", h))
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(Color(hex: textHex).opacity(0.6))
|
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||||
.offset(y: -6)
|
.offset(y: -6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +214,7 @@ struct HourSlot: View {
|
|||||||
let onShowDay: (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 {
|
private var date: Date {
|
||||||
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||||
@@ -216,7 +222,7 @@ struct HourSlot: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
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)
|
Color.clear.frame(height: hourHeight - 0.5)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ struct CalendarFilterSheet: View {
|
|||||||
let isVisible = !hidden.contains(key)
|
let isVisible = !hidden.contains(key)
|
||||||
Button {
|
Button {
|
||||||
if isVisible { hidden.insert(key) } else { hidden.remove(key) }
|
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: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -158,6 +160,7 @@ struct CalendarFilterSheet: View {
|
|||||||
hidden.remove(key)
|
hidden.remove(key)
|
||||||
banished.insert(key)
|
banished.insert(key)
|
||||||
store.setCalendarBanished(key, banished: true)
|
store.setCalendarBanished(key, banished: true)
|
||||||
|
pushBanishToServer(key: key, hidden: true)
|
||||||
} label: {
|
} label: {
|
||||||
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
|
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
|
||||||
}
|
}
|
||||||
@@ -175,6 +178,19 @@ struct CalendarFilterSheet: View {
|
|||||||
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||||
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
|
(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<String>()
|
var keys = Set<String>()
|
||||||
for cal in localCalendars {
|
for cal in localCalendars {
|
||||||
keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
|
keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
|
||||||
@@ -200,4 +216,11 @@ struct CalendarFilterSheet: View {
|
|||||||
allKeys = keys
|
allKeys = keys
|
||||||
isLoading = false
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct MenuSheet: View {
|
|||||||
@Environment(AppState.self) var appState
|
@Environment(AppState.self) var appState
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
@State private var isSyncing = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
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 {
|
Section {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
dismiss()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
let api: CalendarrAPI
|
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("liquidGlass") private var liquidGlass = false
|
||||||
|
@AppStorage("settingsSync") private var settingsSync = false
|
||||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
||||||
@@ -18,13 +14,17 @@ struct SettingsView: View {
|
|||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
@AppStorage("primaryColor") private var primaryHex = "#4285f4"
|
@AppStorage("primaryColor") private var primaryHex = "#4285f4"
|
||||||
@AppStorage("accentColor") private var accentHex = "#ea4335"
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView(L10n.t("settings.loading", appLang))
|
|
||||||
} else {
|
|
||||||
Form {
|
Form {
|
||||||
liquidGlassSection
|
liquidGlassSection
|
||||||
cacheSection
|
cacheSection
|
||||||
@@ -35,48 +35,31 @@ struct SettingsView: View {
|
|||||||
ansichtSection
|
ansichtSection
|
||||||
stundenSection
|
stundenSection
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(L10n.t("settings.title", appLang))
|
.navigationTitle(L10n.t("settings.title", appLang))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
Task { await save() }
|
|
||||||
} label: {
|
|
||||||
if isSaving {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Text(L10n.t("settings.save", appLang)).bold()
|
|
||||||
}
|
}
|
||||||
}
|
// Reflect the latest server values when opening the screen.
|
||||||
.disabled(isSaving)
|
.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
|
||||||
.overlay(alignment: .bottom) {
|
// sent based on the sync toggle, so every change can simply call it.
|
||||||
if showToast {
|
.onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
Text(toast)
|
.onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.padding(.horizontal, 20)
|
.onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.padding(.vertical, 10)
|
.onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.background(.regularMaterial)
|
.onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.clipShape(Capsule())
|
.onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
.padding(.bottom, 20)
|
.onChange(of: dividerHex) { _, _ in SettingsSync.push(api: api) }
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) }
|
||||||
}
|
.onChange(of: textContrast) { _, _ in SettingsSync.push(api: api) }
|
||||||
}
|
.onChange(of: lineContrast) { _, _ in SettingsSync.push(api: api) }
|
||||||
.animation(.easeInOut, value: showToast)
|
.onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) }
|
||||||
}
|
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
|
||||||
.task { await load() }
|
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
|
||||||
// Live-update widgets the moment any appearance value changes, so the
|
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
|
||||||
// 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() }
|
|
||||||
.onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
|
.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
|
// MARK: – Liquid Glass
|
||||||
@@ -97,10 +80,25 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(Color.accentColor)
|
.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: {
|
} header: {
|
||||||
Text(L10n.t("settings.appdesign", appLang))
|
Text(L10n.t("settings.appdesign", appLang))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(L10n.t("settings.liquidglass.footer", appLang))
|
Text(L10n.t("settings.sync.footer", appLang))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +175,7 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
ContrastSelector(
|
ContrastSelector(
|
||||||
value: $settings.textContrast,
|
value: $textContrast,
|
||||||
options: [
|
options: [
|
||||||
(1, L10n.t("settings.contrast.dark", appLang)),
|
(1, L10n.t("settings.contrast.dark", appLang)),
|
||||||
(2, L10n.t("settings.contrast.medium", appLang)),
|
(2, L10n.t("settings.contrast.medium", appLang)),
|
||||||
@@ -201,7 +199,7 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
ContrastSelector(
|
ContrastSelector(
|
||||||
value: $settings.lineContrast,
|
value: $lineContrast,
|
||||||
options: [
|
options: [
|
||||||
(1, L10n.t("settings.linecontrast.barely", appLang)),
|
(1, L10n.t("settings.linecontrast.barely", appLang)),
|
||||||
(2, L10n.t("settings.linecontrast.subtle", appLang)),
|
(2, L10n.t("settings.linecontrast.subtle", appLang)),
|
||||||
@@ -218,18 +216,18 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var ansichtSection: some View {
|
var ansichtSection: some View {
|
||||||
Section(L10n.t("settings.calview", appLang)) {
|
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.month", appLang)).tag("month")
|
||||||
Text(L10n.t("view.week", appLang)).tag("week")
|
Text(L10n.t("view.week", appLang)).tag("week")
|
||||||
Text(L10n.t("view.day", appLang)).tag("day")
|
Text(L10n.t("view.day", appLang)).tag("day")
|
||||||
Text(L10n.t("view.quarter", appLang)).tag("quarter")
|
Text(L10n.t("view.quarter", appLang)).tag("quarter")
|
||||||
Text(L10n.t("view.agenda", appLang)).tag("agenda")
|
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.monday", appLang)).tag("monday")
|
||||||
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
|
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)
|
.tint(Color.accentColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +243,7 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
ContrastSelector(
|
ContrastSelector(
|
||||||
value: $settings.hourHeight,
|
value: $hourHeight,
|
||||||
options: [
|
options: [
|
||||||
(28, L10n.t("settings.hourheight.compact", appLang)),
|
(28, L10n.t("settings.hourheight.compact", appLang)),
|
||||||
(44, L10n.t("settings.hourheight.normal", 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
|
// MARK: – Reusable Components
|
||||||
|
|||||||
Reference in New Issue
Block a user