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

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

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

@@ -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])
}
}

View 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)
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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())

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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 14 `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 14 `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)
}
}

View File

@@ -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())

View File

@@ -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<String>()
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) }
}
}

View File

@@ -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()
}
}

View File

@@ -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